Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 50 additions & 6 deletions apps/api/src/evograph/api/routes/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -226,13 +250,33 @@ 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,
name=t.name,
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
]
4 changes: 4 additions & 0 deletions apps/api/src/evograph/api/schemas/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -22,3 +24,5 @@ class NeighborOut(BaseModel):
rank: str
distance: float
mi_norm: float
align_len: int
shared_rank: str | None = None
22 changes: 20 additions & 2 deletions apps/api/tests/test_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)])
Expand All @@ -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, [])
Expand Down Expand Up @@ -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
17 changes: 14 additions & 3 deletions apps/web/src/__tests__/GraphPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
],
};

Expand Down Expand Up @@ -113,4 +113,15 @@ describe("GraphPage", () => {
render(<GraphPage />);
expect(screen.queryByPlaceholderText("Search nodes...")).not.toBeInTheDocument();
});

it("shows MI metrics summary after loading", async () => {
mockGetMiNetwork.mockResolvedValue(mockGraph);
render(<GraphPage />);

await waitFor(() => {
expect(screen.getByText("Avg NMI")).toBeInTheDocument();
expect(screen.getByText("Median")).toBeInTheDocument();
expect(screen.getByText("Range")).toBeInTheDocument();
});
});
});
37 changes: 37 additions & 0 deletions apps/web/src/__tests__/TaxonDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<TaxonDetailPage />);
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(<TaxonDetailPage />);
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();
});
});
});
107 changes: 107 additions & 0 deletions apps/web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading