diff --git a/apps/api/src/evograph/api/routes/graph.py b/apps/api/src/evograph/api/routes/graph.py
index 67b843f..06ac325 100644
--- a/apps/api/src/evograph/api/routes/graph.py
+++ b/apps/api/src/evograph/api/routes/graph.py
@@ -94,6 +94,8 @@ def get_subtree_graph(
dst=e.dst_ott_id,
kind="mi",
distance=e.distance,
+ mi_norm=e.mi_norm,
+ align_len=e.align_len,
)
)
@@ -153,15 +155,15 @@ def get_mi_network(
# Build MI edges — deduplicate to undirected (keep the one with lower distance
# when both A->B and B->A exist, otherwise keep the single direction)
- seen_pairs: dict[tuple[int, int], float] = {}
+ seen_pairs: dict[tuple[int, int], tuple[float, float, int]] = {}
for e in all_edges:
pair = (min(e.src_ott_id, e.dst_ott_id), max(e.src_ott_id, e.dst_ott_id))
- if pair not in seen_pairs or e.distance < seen_pairs[pair]:
- seen_pairs[pair] = e.distance
+ if pair not in seen_pairs or e.distance < seen_pairs[pair][0]:
+ seen_pairs[pair] = (e.distance, e.mi_norm, e.align_len)
mi_edges = [
- GraphEdge(src=a, dst=b, kind="mi", distance=dist)
- for (a, b), dist in seen_pairs.items()
+ GraphEdge(src=a, dst=b, kind="mi", distance=dist, mi_norm=nmi, align_len=alen)
+ for (a, b), (dist, nmi, alen) in seen_pairs.items()
]
# Add taxonomy edges: connect species to their parent genus/family
@@ -202,6 +204,27 @@ def get_mi_network(
return result
+def _find_shared_rank(
+ src_lineage: list[int] | None,
+ dst_lineage: list[int] | None,
+ rank_lookup: dict[int, str],
+) -> str | None:
+ """Find the deepest shared taxonomic rank between two taxa.
+
+ Lineage arrays run from root -> parent (not including self).
+ Walk dst lineage from deepest to shallowest to find the first common ancestor.
+ """
+ if not src_lineage or not dst_lineage:
+ return None
+
+ src_set = set(src_lineage)
+ for ott_id in reversed(dst_lineage):
+ if ott_id in src_set:
+ return rank_lookup.get(ott_id)
+
+ return None
+
+
@router.get("/graph/neighbors/{ott_id}", response_model=list[NeighborOut])
def get_neighbors(
ott_id: int,
@@ -211,7 +234,8 @@ def get_neighbors(
"""Get k nearest MI-neighbors for a taxon.
Query Edge table where src_ott_id = ott_id, order by distance, limit k.
- Join with Taxon to get name/rank.
+ Join with Taxon to get name/rank. Computes shared taxonomic rank
+ using lineage arrays to show taxonomy-vs-similarity coherence.
"""
taxon = db.query(Taxon).filter(Taxon.ott_id == ott_id).first()
if taxon is None:
@@ -226,6 +250,24 @@ def get_neighbors(
.all()
)
+ # Collect all lineage ott_ids to batch-lookup ranks
+ all_lineage_ids: set[int] = set()
+ src_lineage = taxon.lineage or []
+ all_lineage_ids.update(src_lineage)
+ for _e, t in rows:
+ if t.lineage:
+ all_lineage_ids.update(t.lineage)
+
+ # Batch-fetch ranks for all lineage ancestors
+ rank_lookup: dict[int, str] = {}
+ if all_lineage_ids:
+ ancestor_rows = (
+ db.query(Taxon.ott_id, Taxon.rank)
+ .filter(Taxon.ott_id.in_(all_lineage_ids))
+ .all()
+ )
+ rank_lookup = {ott: rank for ott, rank in ancestor_rows}
+
return [
NeighborOut(
ott_id=t.ott_id,
@@ -233,6 +275,8 @@ def get_neighbors(
rank=t.rank,
distance=e.distance,
mi_norm=e.mi_norm,
+ align_len=e.align_len,
+ shared_rank=_find_shared_rank(src_lineage, t.lineage, rank_lookup),
)
for e, t in rows
]
diff --git a/apps/api/src/evograph/api/schemas/graph.py b/apps/api/src/evograph/api/schemas/graph.py
index f51b02c..f6b9c8a 100644
--- a/apps/api/src/evograph/api/schemas/graph.py
+++ b/apps/api/src/evograph/api/schemas/graph.py
@@ -11,6 +11,8 @@ class GraphEdge(BaseModel):
dst: int
kind: str # "taxonomy" | "mi"
distance: float | None = None
+ mi_norm: float | None = None
+ align_len: int | None = None
class GraphResponse(BaseModel):
nodes: list[Node]
@@ -22,3 +24,5 @@ class NeighborOut(BaseModel):
rank: str
distance: float
mi_norm: float
+ align_len: int
+ shared_rank: str | None = None
diff --git a/apps/api/tests/test_graph.py b/apps/api/tests/test_graph.py
index bbeab39..0134a8f 100644
--- a/apps/api/tests/test_graph.py
+++ b/apps/api/tests/test_graph.py
@@ -100,13 +100,15 @@ def test_mi_network_edge_schema(self, client, mock_db):
assert "src" in e
assert "dst" in e
assert "distance" in e
+ assert "mi_norm" in e
+ assert "align_len" in e
class TestNeighbors:
def test_neighbors_returns_sorted(self, client, mock_db):
taxon = _make_taxon(700118, "Corvus corax", "species")
corone = _make_taxon(893498, "Corvus corone", "species")
- edge = _make_edge(700118, 893498, distance=0.15, mi_norm=0.85)
+ edge = _make_edge(700118, 893498, distance=0.15, mi_norm=0.85, align_len=542)
mock_db.set(Taxon, [taxon])
mock_db.set((Edge, Taxon), [(edge, corone)])
@@ -119,6 +121,7 @@ def test_neighbors_returns_sorted(self, client, mock_db):
assert data[0]["ott_id"] == 893498
assert data[0]["distance"] == 0.15
assert data[0]["mi_norm"] == 0.85
+ assert data[0]["align_len"] == 542
def test_neighbors_not_found(self, client, mock_db):
mock_db.set(Taxon, [])
@@ -158,4 +161,19 @@ def test_neighbor_schema(self, client, mock_db):
resp = client.get("/v1/graph/neighbors/700118")
item = resp.json()[0]
- assert set(item.keys()) == {"ott_id", "name", "rank", "distance", "mi_norm"}
+ assert set(item.keys()) == {
+ "ott_id", "name", "rank", "distance", "mi_norm",
+ "align_len", "shared_rank",
+ }
+
+ def test_neighbor_shared_rank_null_without_lineage(self, client, mock_db):
+ taxon = _make_taxon(700118, "Corvus corax", "species")
+ corone = _make_taxon(893498, "Corvus corone", "species")
+ edge = _make_edge(700118, 893498, distance=0.15, mi_norm=0.85)
+
+ mock_db.set(Taxon, [taxon])
+ mock_db.set((Edge, Taxon), [(edge, corone)])
+
+ resp = client.get("/v1/graph/neighbors/700118")
+ data = resp.json()
+ assert data[0]["shared_rank"] is None
diff --git a/apps/web/src/__tests__/GraphPage.test.tsx b/apps/web/src/__tests__/GraphPage.test.tsx
index 226f94f..46004b5 100644
--- a/apps/web/src/__tests__/GraphPage.test.tsx
+++ b/apps/web/src/__tests__/GraphPage.test.tsx
@@ -24,9 +24,9 @@ const mockGraph = {
{ ott_id: 3, name: "Corvus", rank: "genus", image_url: null },
],
edges: [
- { src: 1, dst: 2, kind: "mi" as const, distance: 0.15 },
- { src: 3, dst: 1, kind: "taxonomy" as const, distance: null },
- { src: 3, dst: 2, kind: "taxonomy" as const, distance: null },
+ { src: 1, dst: 2, kind: "mi" as const, distance: 0.15, mi_norm: 0.85, align_len: 600 },
+ { src: 3, dst: 1, kind: "taxonomy" as const, distance: null, mi_norm: null, align_len: null },
+ { src: 3, dst: 2, kind: "taxonomy" as const, distance: null, mi_norm: null, align_len: null },
],
};
@@ -113,4 +113,15 @@ describe("GraphPage", () => {
render( );
expect(screen.queryByPlaceholderText("Search nodes...")).not.toBeInTheDocument();
});
+
+ it("shows MI metrics summary after loading", async () => {
+ mockGetMiNetwork.mockResolvedValue(mockGraph);
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Avg NMI")).toBeInTheDocument();
+ expect(screen.getByText("Median")).toBeInTheDocument();
+ expect(screen.getByText("Range")).toBeInTheDocument();
+ });
+ });
});
diff --git a/apps/web/src/__tests__/TaxonDetailPage.test.tsx b/apps/web/src/__tests__/TaxonDetailPage.test.tsx
index cf046a5..7c846f1 100644
--- a/apps/web/src/__tests__/TaxonDetailPage.test.tsx
+++ b/apps/web/src/__tests__/TaxonDetailPage.test.tsx
@@ -129,4 +129,41 @@ describe("TaxonDetailPage", () => {
expect(screen.getByText(/Network error/)).toBeInTheDocument();
});
});
+
+ it("renders MI neighbors with NMI similarity", async () => {
+ const neighbors = [
+ {
+ ott_id: 100001,
+ name: "Pica pica",
+ rank: "species",
+ distance: 0.15,
+ mi_norm: 0.85,
+ align_len: 542,
+ shared_rank: "family",
+ },
+ ];
+ (getNeighbors as jest.Mock).mockResolvedValue(neighbors);
+ render( );
+ await waitFor(() => {
+ expect(screen.getByText(/85% NMI/)).toBeInTheDocument();
+ expect(screen.getByText(/542 cols/)).toBeInTheDocument();
+ expect(screen.getByText("Pica pica")).toBeInTheDocument();
+ });
+ });
+
+ it("shows taxonomic coherence summary for neighbors", async () => {
+ const neighbors = [
+ { ott_id: 1, name: "Species A", rank: "species", distance: 0.1, mi_norm: 0.9, align_len: 600, shared_rank: "genus" },
+ { ott_id: 2, name: "Species B", rank: "species", distance: 0.2, mi_norm: 0.8, align_len: 550, shared_rank: "family" },
+ { ott_id: 3, name: "Species C", rank: "species", distance: 0.4, mi_norm: 0.6, align_len: 500, shared_rank: "order" },
+ ];
+ (getNeighbors as jest.Mock).mockResolvedValue(neighbors);
+ render( );
+ await waitFor(() => {
+ expect(screen.getByText("Taxonomic coherence:")).toBeInTheDocument();
+ expect(screen.getByText("1 same genus")).toBeInTheDocument();
+ expect(screen.getByText("1 same family")).toBeInTheDocument();
+ expect(screen.getByText("1 cross-family")).toBeInTheDocument();
+ });
+ });
});
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 34a5ea3..fb2bf91 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -211,6 +211,82 @@ input:focus {
color: #555;
}
+/* ── Edge tooltip ────────────────────────────────── */
+
+.graph-edge-tooltip {
+ position: absolute;
+ z-index: 1000;
+ background: rgba(15, 18, 28, 0.95);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 0.5rem 0.75rem;
+ pointer-events: none;
+ max-width: 320px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
+}
+
+.graph-edge-tooltip-names {
+ display: flex;
+ gap: 0.4rem;
+ align-items: center;
+ font-size: 0.8rem;
+ margin-bottom: 0.35rem;
+ color: var(--fg);
+}
+
+.graph-edge-tooltip-metrics {
+ display: flex;
+ gap: 0.75rem;
+ font-size: 0.75rem;
+ color: #aaa;
+ font-variant-numeric: tabular-nums;
+}
+
+.graph-edge-tooltip-metrics strong {
+ color: #2a9d8f;
+}
+
+/* ── MI stats bar ────────────────────────────────── */
+
+.mi-stats-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ align-items: center;
+ padding: 0.5rem 0.85rem;
+ margin-bottom: 0.5rem;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ font-size: 0.8rem;
+}
+
+.mi-stats-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.1rem;
+}
+
+.mi-stats-label {
+ font-size: 0.65rem;
+ color: #888;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.mi-stats-value {
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ color: var(--fg);
+}
+
+.mi-stats-sep {
+ width: 1px;
+ height: 28px;
+ background: var(--border);
+}
+
/* ── Breadcrumbs ──────────────────────────────────── */
.breadcrumbs {
@@ -567,6 +643,37 @@ input:focus {
white-space: nowrap;
}
+.neighbor-meta {
+ font-size: 0.7rem;
+ color: #666;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+.neighbor-shared-rank {
+ font-size: 0.65rem;
+ font-weight: 600;
+ padding: 0.1rem 0.4rem;
+ border-radius: 3px;
+ white-space: nowrap;
+ flex-shrink: 0;
+ text-transform: capitalize;
+}
+
+.neighbor-coherence {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ align-items: center;
+ font-size: 0.8rem;
+ color: #888;
+ margin-bottom: 0.75rem;
+ padding: 0.5rem 0.75rem;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+}
+
/* ── Sequence viewer ─────────────────────────────── */
.sequence-card {
diff --git a/apps/web/src/app/graph/page.tsx b/apps/web/src/app/graph/page.tsx
index b7d17b8..be272f4 100644
--- a/apps/web/src/app/graph/page.tsx
+++ b/apps/web/src/app/graph/page.tsx
@@ -92,13 +92,37 @@ export default function GraphPage() {
.finally(() => setLoading(false));
}, []);
+ const miEdges = graph ? graph.edges.filter((e) => e.kind === "mi") : [];
+ const miCount = miEdges.length;
+ const speciesCount = graph ? graph.nodes.length : 0;
+
+ // Compute MI metrics summary
+ const miStats = useMemo(() => {
+ if (miEdges.length === 0) return null;
+ const nmiValues = miEdges
+ .map((e) => e.mi_norm)
+ .filter((v): v is number => v != null);
+ if (nmiValues.length === 0) return null;
+
+ nmiValues.sort((a, b) => a - b);
+ const sum = nmiValues.reduce((a, b) => a + b, 0);
+ const avg = sum / nmiValues.length;
+ const median = nmiValues[Math.floor(nmiValues.length / 2)];
+ const min = nmiValues[0];
+ const max = nmiValues[nmiValues.length - 1];
+
+ // Distribution buckets for NMI similarity
+ const highSim = nmiValues.filter((v) => v >= 0.7).length;
+ const medSim = nmiValues.filter((v) => v >= 0.4 && v < 0.7).length;
+ const lowSim = nmiValues.filter((v) => v < 0.4).length;
+
+ return { avg, median, min, max, highSim, medSim, lowSim, total: nmiValues.length };
+ }, [miEdges]);
+
if (error) {
return
Failed to load graph: {error}
;
}
- const miCount = graph ? graph.edges.filter((e) => e.kind === "mi").length : 0;
- const speciesCount = graph ? graph.nodes.length : 0;
-
return (
@@ -108,7 +132,7 @@ export default function GraphPage() {
Species with COI barcodes connected by mutual information similarity.
- Closer species have thicker, brighter edges. Hover to highlight, click to view details.
+ Closer species have thicker, brighter edges. Hover edges for MI metrics, click nodes to view details.
{graph && !loading && (
@@ -121,6 +145,37 @@ export default function GraphPage() {
)}
+ {/* MI Metrics Summary */}
+ {miStats && !loading && (
+
+
+ Avg NMI
+ {Math.round(miStats.avg * 100)}%
+
+
+ Median
+ {Math.round(miStats.median * 100)}%
+
+
+ Range
+ {Math.round(miStats.min * 100)}–{Math.round(miStats.max * 100)}%
+
+
+
+ High (≥70%)
+ {miStats.highSim}
+
+
+ Medium (40–70%)
+ {miStats.medSim}
+
+
+ Low (<40%)
+ {miStats.lowSim}
+
+
+ )}
+
{loading ? (
) : graph ? (
diff --git a/apps/web/src/app/taxa/[ottId]/page.tsx b/apps/web/src/app/taxa/[ottId]/page.tsx
index 4d1e265..6b5af9d 100644
--- a/apps/web/src/app/taxa/[ottId]/page.tsx
+++ b/apps/web/src/app/taxa/[ottId]/page.tsx
@@ -98,25 +98,48 @@ function StatsBar({ items }: { items: TaxonSummary[] }) {
);
}
+// ── shared rank colors ──────────────────────────────
+const SHARED_RANK_COLORS: Record = {
+ genus: "#81c784",
+ family: "#fff176",
+ subfamily: "#dce775",
+ order: "#ffb74d",
+ class: "#e57373",
+};
+
// ── neighbor card ───────────────────────────────────
-function NeighborCard({ neighbor, maxDist }: { neighbor: NeighborOut; maxDist: number }) {
- // Similarity: 1 = identical, 0 = maximally distant
- const similarity = Math.max(0, 1 - neighbor.distance / maxDist);
- const pct = Math.round(similarity * 100);
- // Color: green (similar) → orange (distant)
- const hue = Math.round(similarity * 120); // 120=green, 0=red
+function NeighborCard({ neighbor }: { neighbor: NeighborOut }) {
+ // Use actual NMI (normalized mutual information) as similarity percentage
+ const nmiPct = Math.round(neighbor.mi_norm * 100);
+ // Color: green (high NMI) → red (low NMI)
+ const hue = Math.round(neighbor.mi_norm * 120); // 120=green, 0=red
const barColor = `hsl(${hue}, 70%, 50%)`;
return (
-
+
-
- {neighbor.name}
-
-
- {pct}% similar
-
+
+
+ {neighbor.name}
+
+ {neighbor.shared_rank && (
+
+ {neighbor.shared_rank}
+
+ )}
+
+
+
+ {nmiPct}% NMI
+
+
+ {neighbor.align_len} cols
+
+
);
@@ -179,9 +202,9 @@ export default function TaxonDetailPage() {
const showGraph = hasMiEdges && neighbors.length > 0;
const grouped = groupByRank(allChildren);
const hasMoreChildren = allChildren.length < taxon.total_children;
- const neighborMaxDist = neighbors.length > 0
- ? Math.max(...neighbors.map((n) => n.distance)) * 1.1 // 10% headroom
- : 1;
+ // Count how many neighbors share genus vs family for the summary
+ const genusCount = neighbors.filter((n) => n.shared_rank === "genus").length;
+ const familyCount = neighbors.filter((n) => n.shared_rank === "family" || n.shared_rank === "subfamily").length;
return (
@@ -322,9 +345,27 @@ export default function TaxonDetailPage() {
{neighbors.length > 0 && (
MI Neighbors ({neighbors.length})
+
+ Taxonomic coherence:
+ {genusCount > 0 && (
+
+ {genusCount} same genus
+
+ )}
+ {familyCount > 0 && (
+
+ {familyCount} same family
+
+ )}
+ {neighbors.length - genusCount - familyCount > 0 && (
+
+ {neighbors.length - genusCount - familyCount} cross-family
+
+ )}
+
{neighbors.map((n) => (
-
+
))}
diff --git a/apps/web/src/components/GraphViewSigma.tsx b/apps/web/src/components/GraphViewSigma.tsx
index a432680..cb81390 100644
--- a/apps/web/src/components/GraphViewSigma.tsx
+++ b/apps/web/src/components/GraphViewSigma.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useRef, useCallback } from "react";
+import { useEffect, useRef, useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import Graph from "graphology";
import Sigma from "sigma";
@@ -93,6 +93,8 @@ function buildGraph(data: GraphResponse, mode: "force" | "radial"): Graph {
origColor: color,
origSize: size,
distance: dist,
+ miNorm: e.mi_norm,
+ alignLen: e.align_len,
});
} else if (mode === "radial") {
// Only show taxonomy edges in tree mode, very faint
@@ -155,6 +157,15 @@ export default function GraphViewSigma({
const graphRef = useRef
(null);
const router = useRouter();
const hoveredRef = useRef(null);
+ const [edgeTooltip, setEdgeTooltip] = useState<{
+ x: number;
+ y: number;
+ srcName: string;
+ dstName: string;
+ miNorm: number;
+ distance: number;
+ alignLen: number | null;
+ } | null>(null);
/** Highlight a node's neighborhood, dim everything else */
const focusNode = useCallback((g: Graph, nodeId: string | null) => {
@@ -202,6 +213,7 @@ export default function GraphViewSigma({
allowInvalidContainer: true,
renderLabels: true,
renderEdgeLabels: false,
+ enableEdgeEvents: true,
defaultNodeColor: "#b5a7d5",
defaultEdgeColor: blendWithBg(42, 157, 143, 0.08),
labelColor: { color: "#9a958a" },
@@ -254,6 +266,28 @@ export default function GraphViewSigma({
if (onNodeDoubleClick) onNodeDoubleClick(Number(node));
});
+ // Edge hover → show MI metrics tooltip
+ sigma.on("enterEdge", ({ edge, event }) => {
+ const attr = g.getEdgeAttributes(edge);
+ if (attr.kind !== "mi") return;
+ const src = g.source(edge);
+ const dst = g.target(edge);
+ const srcName = g.getNodeAttribute(src, "label") as string;
+ const dstName = g.getNodeAttribute(dst, "label") as string;
+ setEdgeTooltip({
+ x: (event as unknown as { x: number }).x,
+ y: (event as unknown as { y: number }).y,
+ srcName,
+ dstName,
+ miNorm: attr.miNorm as number ?? 0,
+ distance: attr.distance as number ?? 0,
+ alignLen: attr.alignLen as number | null ?? null,
+ });
+ });
+ sigma.on("leaveEdge", () => {
+ setEdgeTooltip(null);
+ });
+
return () => {
ro.disconnect();
sigma.kill();
@@ -285,6 +319,28 @@ export default function GraphViewSigma({
className="graph-sigma-container"
style={{ height }}
/>
+ {edgeTooltip && (
+
+
+ {edgeTooltip.srcName}
+ ↔
+ {edgeTooltip.dstName}
+
+
+ NMI: {Math.round(edgeTooltip.miNorm * 100)}%
+ Distance: {edgeTooltip.distance.toFixed(3)}
+ {edgeTooltip.alignLen && (
+ Alignment: {edgeTooltip.alignLen} cols
+ )}
+
+
+ )}
{LEGEND_RANKS.map((rank) => (
diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts
index 1d23b68..e862c6a 100644
--- a/apps/web/src/lib/types.ts
+++ b/apps/web/src/lib/types.ts
@@ -64,6 +64,8 @@ export interface GraphEdge {
dst: number;
kind: "taxonomy" | "mi";
distance: number | null;
+ mi_norm: number | null;
+ align_len: number | null;
}
export interface GraphResponse {
@@ -77,6 +79,8 @@ export interface NeighborOut {
rank: string;
distance: number;
mi_norm: number;
+ align_len: number;
+ shared_rank: string | null;
}
export interface StatsResponse {