Skip to content

Commit ec62c17

Browse files
Robbie1977claude
andcommitted
Add graph representations for connectivity queries (include_graph)
Add a new graph_builder module that generates basic_graph JSON structures from connectivity query results, compatible with VFBchat's BasicGraphView. Nodes are enriched with symbols, labels, and uniqueFacets via a batch Neo4j lookup, and grouped/colored by neurotransmitter type, sensory system, or brain region. Graphs are generated as a post-processing step, keeping caching layers untouched (no duplicate cache entries). When node/edge limits are exceeded (80/200), a clipped field notifies consumers of truncation. Supported via include_graph=true on: - /query_connectivity (class-level and per-neuron modes) - /run_query for NeuronNeuronConnectivityQuery, NeuronRegionConnectivityQuery, DownstreamClassConnectivity, UpstreamClassConnectivity Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ddcc503 commit ec62c17

5 files changed

Lines changed: 1062 additions & 6 deletions

File tree

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
here = path.abspath(path.dirname(__file__))
55

6-
__version__ = "1.7.4"
6+
__version__ = "1.8.0"
77

88
# Get the long description from the README file
99
with open(path.join(here, 'README.md')) as f:

src/test/test_graph_builder.py

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
"""Tests for graph_builder module — graph construction from connectivity data."""
2+
import pytest
3+
4+
from vfbquery.graph_builder import (
5+
assign_group,
6+
build_graph,
7+
graph_from_query_connectivity,
8+
graph_from_neuron_neuron,
9+
graph_from_neuron_region,
10+
graph_from_downstream_class,
11+
graph_from_upstream_class,
12+
_strip_markdown_link,
13+
_extract_id_from_markdown,
14+
_node_display_label,
15+
MAX_NODES,
16+
MAX_EDGES,
17+
GRAPH_VERSION,
18+
)
19+
20+
21+
# ---------------------------------------------------------------------------
22+
# assign_group tests
23+
# ---------------------------------------------------------------------------
24+
25+
class TestAssignGroup:
26+
def test_neurotransmitter_from_tags(self):
27+
assert assign_group(["cholinergic neuron", "visual system"]) == "cholinergic"
28+
29+
def test_gabaergic_from_tags(self):
30+
assert assign_group(["GABAergic neuron"]) == "GABAergic"
31+
32+
def test_glutamatergic_from_label(self):
33+
assert assign_group(None, "adult glutamatergic neuron Tm5") == "glutamatergic"
34+
35+
def test_system_from_tags(self):
36+
assert assign_group(["visual projection neuron"]) == "visual"
37+
38+
def test_region_from_label(self):
39+
assert assign_group(None, "adult medulla neuron Tm1") == "medulla"
40+
41+
def test_region_mushroom_body(self):
42+
assert assign_group(None, "mushroom body output neuron MBON-01") == "mushroom body"
43+
44+
def test_pipe_separated_tags(self):
45+
assert assign_group("cholinergic|visual system") == "cholinergic"
46+
47+
def test_unknown_returns_other(self):
48+
assert assign_group(["something unknown"]) == "other"
49+
50+
def test_none_tags_none_label(self):
51+
assert assign_group(None, "") == "other"
52+
53+
def test_priority_nt_over_system(self):
54+
"""Neurotransmitter should win over system."""
55+
assert assign_group(["cholinergic", "visual"]) == "cholinergic"
56+
57+
58+
# ---------------------------------------------------------------------------
59+
# Markdown helpers
60+
# ---------------------------------------------------------------------------
61+
62+
class TestMarkdownHelpers:
63+
def test_strip_markdown_link(self):
64+
assert _strip_markdown_link("[Tm1](FBbt_001)") == "Tm1"
65+
66+
def test_strip_plain_text(self):
67+
assert _strip_markdown_link("plain text") == "plain text"
68+
69+
def test_strip_empty(self):
70+
assert _strip_markdown_link("") == ""
71+
72+
def test_extract_id_from_markdown(self):
73+
assert _extract_id_from_markdown("[Tm1](FBbt_001)") == "FBbt_001"
74+
75+
def test_extract_id_plain(self):
76+
assert _extract_id_from_markdown("FBbt_001") == "FBbt_001"
77+
78+
def test_node_display_label_prefers_symbol(self):
79+
assert _node_display_label({"symbol": "Tm1", "label": "adult medulla neuron Tm1"}) == "Tm1"
80+
81+
def test_node_display_label_falls_back_to_label(self):
82+
assert _node_display_label({"symbol": "", "label": "some neuron"}) == "some neuron"
83+
84+
85+
# ---------------------------------------------------------------------------
86+
# build_graph tests
87+
# ---------------------------------------------------------------------------
88+
89+
class TestBuildGraph:
90+
def test_basic_structure(self):
91+
nodes = [
92+
{"id": "a", "label": "A", "full_label": "Node A", "group": "other"},
93+
{"id": "b", "label": "B", "full_label": "Node B", "group": "other"},
94+
]
95+
edges = [{"source": "a", "target": "b", "weight": 10}]
96+
g = build_graph(nodes, edges, title="Test")
97+
98+
assert g["type"] == "basic_graph"
99+
assert g["version"] == GRAPH_VERSION
100+
assert g["title"] == "Test"
101+
assert g["directed"] is True
102+
assert len(g["nodes"]) == 2
103+
assert len(g["edges"]) == 1
104+
assert "clipped" not in g
105+
106+
def test_deduplication(self):
107+
nodes = [
108+
{"id": "a", "label": "A", "full_label": "A", "group": "x"},
109+
{"id": "a", "label": "A", "full_label": "A", "group": "x"},
110+
{"id": "b", "label": "B", "full_label": "B", "group": "x"},
111+
]
112+
edges = []
113+
g = build_graph(nodes, edges)
114+
assert len(g["nodes"]) == 2
115+
116+
def test_clipping_notification_edges(self):
117+
nodes = [
118+
{"id": f"n{i}", "label": f"N{i}", "full_label": f"N{i}", "group": "x"}
119+
for i in range(5)
120+
]
121+
edges = [
122+
{"source": "n0", "target": f"n{i % 5}", "weight": i}
123+
for i in range(MAX_EDGES + 50)
124+
]
125+
g = build_graph(nodes, edges)
126+
assert "clipped" in g
127+
assert g["clipped"]["edges_total"] == MAX_EDGES + 50
128+
assert g["clipped"]["edges_included"] == MAX_EDGES
129+
130+
def test_clipping_notification_nodes(self):
131+
nodes = [
132+
{"id": f"n{i}", "label": f"N{i}", "full_label": f"N{i}", "group": "x"}
133+
for i in range(MAX_NODES + 20)
134+
]
135+
# Create edges only between first MAX_NODES nodes so some nodes have degree
136+
edges = [
137+
{"source": f"n{i}", "target": f"n{i+1}", "weight": 1}
138+
for i in range(min(MAX_NODES, len(nodes) - 1))
139+
]
140+
g = build_graph(nodes, edges)
141+
assert "clipped" in g
142+
assert g["clipped"]["nodes_total"] == MAX_NODES + 20
143+
assert g["clipped"]["nodes_included"] == MAX_NODES
144+
145+
def test_no_clipping_when_under_limits(self):
146+
nodes = [
147+
{"id": "a", "label": "A", "full_label": "A", "group": "x"},
148+
]
149+
edges = []
150+
g = build_graph(nodes, edges)
151+
assert "clipped" not in g
152+
153+
def test_auto_colour_assignment(self):
154+
nodes = [
155+
{"id": "a", "label": "A", "full_label": "A", "group": "cholinergic"},
156+
{"id": "b", "label": "B", "full_label": "B", "group": "GABAergic"},
157+
]
158+
edges = []
159+
g = build_graph(nodes, edges)
160+
colours = {n["id"]: n["color"] for n in g["nodes"]}
161+
assert colours["a"] != colours["b"]
162+
163+
def test_directed_false(self):
164+
g = build_graph([], [], directed=False)
165+
assert g["directed"] is False
166+
167+
168+
# ---------------------------------------------------------------------------
169+
# Converter tests with mock data (no network)
170+
# ---------------------------------------------------------------------------
171+
172+
def _mock_batch_lookup(monkeypatch):
173+
"""Patch batch_lookup_ids to avoid Neo4j calls."""
174+
def fake_batch(ids):
175+
return {
176+
i: {"label": f"Label for {i}", "symbol": f"sym_{i}", "tags": []}
177+
for i in ids
178+
}
179+
import vfbquery.graph_builder as gb
180+
monkeypatch.setattr(gb, "batch_lookup_ids", fake_batch)
181+
182+
183+
class TestGraphFromQueryConnectivity:
184+
def test_class_level(self, monkeypatch):
185+
_mock_batch_lookup(monkeypatch)
186+
connections = [
187+
{
188+
"upstream_class": "Kenyon cell",
189+
"upstream_class_id": "FBbt_001",
190+
"downstream_class": "MBON-01",
191+
"downstream_class_id": "FBbt_002",
192+
"total_upstream_count": 100,
193+
"connected_upstream_count": 50,
194+
"percent_connected": 50,
195+
"pairwise_connections": 200,
196+
"total_weight": 5000,
197+
"average_weight": 25,
198+
},
199+
]
200+
g = graph_from_query_connectivity(connections, group_by_class=True,
201+
upstream_type="Kenyon cell",
202+
downstream_type="MBON-01")
203+
assert g is not None
204+
assert g["type"] == "basic_graph"
205+
assert len(g["nodes"]) == 2
206+
assert len(g["edges"]) == 1
207+
assert g["edges"][0]["weight"] == 5000
208+
assert g["directed"] is True
209+
210+
def test_per_neuron(self, monkeypatch):
211+
_mock_batch_lookup(monkeypatch)
212+
connections = [
213+
{
214+
"upstream_class": "Kenyon cell",
215+
"upstream_class_id": "FBbt_001",
216+
"upstream_neuron_id": "VFB_n001",
217+
"upstream_neuron_name": "KC-alpha 1",
218+
"weight": 42,
219+
"downstream_neuron_id": "VFB_n002",
220+
"downstream_neuron_name": "MBON-01 R",
221+
"downstream_class": "MBON-01",
222+
"downstream_class_id": "FBbt_002",
223+
},
224+
]
225+
g = graph_from_query_connectivity(connections, group_by_class=False)
226+
assert g is not None
227+
assert len(g["nodes"]) == 2
228+
assert g["edges"][0]["weight"] == 42
229+
230+
def test_empty_connections(self, monkeypatch):
231+
_mock_batch_lookup(monkeypatch)
232+
assert graph_from_query_connectivity([], group_by_class=True) is None
233+
234+
235+
class TestGraphFromNeuronNeuron:
236+
def test_basic(self, monkeypatch):
237+
_mock_batch_lookup(monkeypatch)
238+
rows = [
239+
{"id": "VFB_p1", "label": "Partner 1", "outputs": 10, "inputs": 5, "tags": "visual"},
240+
{"id": "VFB_p2", "label": "Partner 2", "outputs": 0, "inputs": 20, "tags": "olfactory"},
241+
]
242+
g = graph_from_neuron_neuron(rows, "VFB_primary", "My Neuron")
243+
assert g is not None
244+
assert len(g["nodes"]) == 3 # primary + 2 partners
245+
# Partner 1: 1 output + 1 input edge; Partner 2: 1 input edge
246+
assert len(g["edges"]) == 3
247+
assert g["directed"] is True
248+
249+
def test_empty(self, monkeypatch):
250+
_mock_batch_lookup(monkeypatch)
251+
assert graph_from_neuron_neuron([], "VFB_x") is None
252+
253+
254+
class TestGraphFromNeuronRegion:
255+
def test_basic(self, monkeypatch):
256+
_mock_batch_lookup(monkeypatch)
257+
rows = [
258+
{"id": "FBbt_r1", "region": "Medulla", "presynaptic_terminals": 100,
259+
"postsynaptic_terminals": 50, "tags": "optic lobe"},
260+
{"id": "FBbt_r2", "region": "Lobula", "presynaptic_terminals": 30,
261+
"postsynaptic_terminals": 10, "tags": "optic lobe"},
262+
]
263+
g = graph_from_neuron_region(rows, "VFB_n1", "Neuron X")
264+
assert g is not None
265+
assert g["directed"] is False
266+
assert len(g["nodes"]) == 3 # primary + 2 regions
267+
assert len(g["edges"]) == 2
268+
assert g["edges"][0]["weight"] == 150 # 100 + 50
269+
270+
def test_empty(self, monkeypatch):
271+
_mock_batch_lookup(monkeypatch)
272+
assert graph_from_neuron_region([], "VFB_x") is None
273+
274+
275+
class TestGraphFromDownstreamClass:
276+
def test_basic(self, monkeypatch):
277+
_mock_batch_lookup(monkeypatch)
278+
rows = [
279+
{"id": "FBbt_d1", "downstream_class": "[MBON-01](FBbt_d1)",
280+
"total_n": 100, "connected_n": 50, "percent_connected": 50,
281+
"pairwise_connections": 200, "total_weight": 5000, "avg_weight": 25},
282+
{"id": "FBbt_d2", "downstream_class": "[Tm1](FBbt_d2)",
283+
"total_n": 80, "connected_n": 40, "percent_connected": 50,
284+
"pairwise_connections": 100, "total_weight": 2000, "avg_weight": 20},
285+
]
286+
g = graph_from_downstream_class(rows, "FBbt_primary", "KC")
287+
assert g is not None
288+
assert g["directed"] is True
289+
assert len(g["nodes"]) == 3 # primary + 2 downstream
290+
assert len(g["edges"]) == 2
291+
# Edges should be primary -> downstream
292+
assert all(e["source"] == "FBbt_primary" for e in g["edges"])
293+
294+
def test_empty(self, monkeypatch):
295+
_mock_batch_lookup(monkeypatch)
296+
assert graph_from_downstream_class([], "FBbt_x") is None
297+
298+
299+
class TestGraphFromUpstreamClass:
300+
def test_basic(self, monkeypatch):
301+
_mock_batch_lookup(monkeypatch)
302+
rows = [
303+
{"id": "FBbt_u1", "upstream_class": "[PN1](FBbt_u1)",
304+
"total_n": 60, "connected_n": 30, "percent_connected": 50,
305+
"pairwise_connections": 150, "total_weight": 3000, "avg_weight": 20},
306+
]
307+
g = graph_from_upstream_class(rows, "FBbt_primary", "KC")
308+
assert g is not None
309+
assert g["directed"] is True
310+
assert len(g["nodes"]) == 2
311+
# Edges should be upstream -> primary
312+
assert g["edges"][0]["source"] == "FBbt_u1"
313+
assert g["edges"][0]["target"] == "FBbt_primary"
314+
315+
def test_empty(self, monkeypatch):
316+
_mock_batch_lookup(monkeypatch)
317+
assert graph_from_upstream_class([], "FBbt_x") is None
318+
319+
320+
# ---------------------------------------------------------------------------
321+
# Integration tests (require network access to Neo4j)
322+
# ---------------------------------------------------------------------------
323+
324+
class TestGraphIntegration:
325+
@pytest.mark.integration
326+
def test_query_connectivity_with_graph(self):
327+
"""query_connectivity result can be converted to a graph."""
328+
from vfbquery.vfb_connectivity import query_connectivity
329+
result = query_connectivity(
330+
upstream_type="giant fiber neuron",
331+
group_by_class=True,
332+
)
333+
assert result["count"] > 0
334+
g = graph_from_query_connectivity(
335+
result["connections"], group_by_class=True,
336+
upstream_type="giant fiber neuron",
337+
)
338+
assert g is not None
339+
assert g["type"] == "basic_graph"
340+
assert len(g["nodes"]) > 0
341+
assert len(g["edges"]) > 0
342+
# Check node structure
343+
for n in g["nodes"]:
344+
assert "id" in n
345+
assert "label" in n
346+
assert "full_label" in n
347+
assert "group" in n
348+
assert "color" in n

src/vfbquery/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .flybase_stocks import resolve_entity, find_stocks
44
from .flybase_combo_pubs import resolve_combination, find_combo_publications
55
from .vfb_connectivity import list_connectome_datasets, query_connectivity
6+
from .graph_builder import build_graph, batch_lookup_ids
67

78
# SOLR-based caching (simplified single-layer approach)
89
try:
@@ -98,4 +99,4 @@ def clear_solr_cache(query_type: str, term_id: str) -> bool:
9899
__solr_caching_available__ = False
99100

100101
# Version information
101-
__version__ = "1.7.4"
102+
__version__ = "1.8.0"

0 commit comments

Comments
 (0)