From bb1072f73c502e4dd46d582716eb8b3431623d4e Mon Sep 17 00:00:00 2001 From: Robbie Court Date: Tue, 7 Jul 2026 20:39:35 +0100 Subject: [PATCH] Match legacy class->instance query inheritance in term_info Reinstate the type-based gate from the legacy term-info builder (uk.ac.vfb.geppetto VFBProcessTermInfoCachedJson) so an Individual of an anatomical or expression-pattern type inherits its parent class's full query menu, anchored on the class. Replace the Technique == "computer graphic" heuristic, which only caught painted domains and dropped confocal instances such as expression patterns (VFB_00020530), splits (VFB_00069525), tracts and muscle. Drive inheritance from a predicate table mirroring the class-menu criteria, anchored on the primary parent class (parents[0]), so an instance shows exactly what its class shows. Add live-integration parity tests. --- ...t_expression_pattern_individual_queries.py | 102 ++++++++++++ src/vfbquery/vfb_queries.py | 154 +++++++----------- 2 files changed, 158 insertions(+), 98 deletions(-) create mode 100644 src/test/test_expression_pattern_individual_queries.py diff --git a/src/test/test_expression_pattern_individual_queries.py b/src/test/test_expression_pattern_individual_queries.py new file mode 100644 index 0000000..a612f05 --- /dev/null +++ b/src/test/test_expression_pattern_individual_queries.py @@ -0,0 +1,102 @@ +""" +Test suite for class->instance query inheritance in term_info. + +The legacy term-info builder (uk.ac.vfb.geppetto VFBProcessTermInfoCachedJson, +gate ~line 1757) brought a class's full query menu down onto Individuals of a +fixed set of anatomical / expression-pattern types, running each query on the +parent class (QueryChecker.check(query, classVariable)). The VFBquery port had +replaced that type-based gate with a Technique == "computer graphic" heuristic, +which only caught painted domains and dropped confocal instances such as +expression-pattern images (R40G10, VFB_00020530) and splits (VFB_00069525). + +The reinstated behaviour: an Individual of one of the inherited types shows +exactly the queries its parent class shows, anchored on the class. These tests +assert that parity: instance inherited-menu ⊇ class menu, and every inherited +query runs on the class rather than the individual. +""" + +import unittest +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from vfbquery.vfb_queries import get_term_info + + +def _menu(term_info): + """query_id -> anchored short_form for each query in a term_info menu.""" + out = {} + for q in (term_info or {}).get("Queries", []) or []: + if isinstance(q, dict): + out[q.get("query")] = q.get("takes", {}).get("default", {}).get("short_form") + return out + + +class TestExpressionPatternIndividualQueries(unittest.TestCase): + """R40G10 expression-pattern image inherits its class's menu.""" + + EP_INDIVIDUAL = "VFB_00020530" # R40G10 in the adult brain (confocal) + EP_CLASS = "VFBexp_FBtp0060056" # P{GMR40G10-GAL4} expression pattern + + @classmethod + def setUpClass(cls): + cls.ind = get_term_info(cls.EP_INDIVIDUAL, preview=False) + cls.cls = get_term_info(cls.EP_CLASS, preview=False) + + def test_is_expression_pattern_individual(self): + if not self.ind: + self.skipTest("term_info unavailable (no live VFB backend)") + self.assertTrue(self.ind.get("IsIndividual")) + self.assertIn("Expression_pattern", self.ind.get("SuperTypes", [])) + + def test_instance_menu_includes_everything_the_class_offers(self): + if not self.ind or not self.cls: + self.skipTest("term_info unavailable (no live VFB backend)") + class_menu = _menu(self.cls) + ind_menu = _menu(self.ind) + # Queries the class offers must all appear on the instance... + missing = set(class_menu) - set(ind_menu) + self.assertFalse(missing, f"instance is missing class queries: {sorted(missing)}") + # ...and each such inherited query must run on the class, not the instance. + for qid in class_menu: + self.assertEqual(ind_menu[qid], self.EP_CLASS, + f"{qid} on the instance should anchor on {self.EP_CLASS}") + + def test_expected_ep_queries_present(self): + if not self.ind: + self.skipTest("term_info unavailable (no live VFB backend)") + ind_menu = _menu(self.ind) + for qid in ("AnatomyExpressedIn", "epFrag", "ListAllAvailableImages", + "NeuronsPartHere", "PartsOf", "SubclassesOf"): + self.assertIn(qid, ind_menu, f"expected {qid} inherited onto the EP instance") + self.assertEqual(ind_menu[qid], self.EP_CLASS) + + def test_no_query_is_anchored_on_the_individual(self): + """Inherited class queries run on the class; none should target the VFB_ instance.""" + for qid, anchor in _menu(self.ind).items(): + self.assertNotEqual(anchor, self.EP_INDIVIDUAL, + f"{qid} should not run on the individual") + + +class TestSplitIndividualQueries(unittest.TestCase): + """A confocal split-GAL4 image was also missed by the technique gate.""" + + SPLIT_INDIVIDUAL = "VFB_00069525" # JRC_SS00810 in the Adult Brain + + def test_split_individual_inherits_ep_queries(self): + ind = get_term_info(self.SPLIT_INDIVIDUAL, preview=False) + if not ind: + self.skipTest("term_info unavailable (no live VFB backend)") + self.assertTrue(ind.get("IsIndividual")) + self.assertIn("Split", ind.get("SuperTypes", [])) + ind_menu = _menu(ind) + # AnatomyExpressedIn is the defining expression-pattern query; it must be + # present and anchored on a class (VFBexp*), not the VFB_ individual. + self.assertIn("AnatomyExpressedIn", ind_menu) + self.assertNotEqual(ind_menu["AnatomyExpressedIn"], self.SPLIT_INDIVIDUAL) + self.assertTrue(str(ind_menu["AnatomyExpressedIn"]).startswith("VFBexp")) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/src/vfbquery/vfb_queries.py b/src/vfbquery/vfb_queries.py index 2bcc50f..6b91802 100644 --- a/src/vfbquery/vfb_queries.py +++ b/src/vfbquery/vfb_queries.py @@ -1346,104 +1346,62 @@ def term_info_parse_object(results, short_form): "default_args": {"fbco_id": sf}, }) - # For individuals that are painted domains of anatomical regions, add parent class queries - if termInfo["IsIndividual"] and termInfo["Technique"] and any('computer' in t.lower() for t in termInfo["Technique"]): - anatomical_classes = [] - - # Check parents - if vfbTerm.parents: - for parent in vfbTerm.parents: - if parent.types and "Class" in parent.types and "Anatomy" in parent.types: - anatomical_classes.append(parent) - - # Check relationships for anatomical classes - if vfbTerm.relationships: - for rel in vfbTerm.relationships: - if hasattr(rel, 'object') and rel.object and hasattr(rel.object, 'types') and rel.object.types: - if "Class" in rel.object.types and "Anatomy" in rel.object.types: - anatomical_classes.append(rel.object) - - # Remove duplicates based on short_form - seen = set() - unique_anatomical_classes = [] - for cls in anatomical_classes: - if cls.short_form not in seen: - seen.add(cls.short_form) - unique_anatomical_classes.append(cls) - - for parent in unique_anatomical_classes: - parent_short_form = parent.short_form - parent_label = parent.label if parent.label else parent_short_form - - # Add queries based on parent types - if "Anatomy" in parent.types or "Synaptic_neuropil" in parent.types or "Synaptic_neuropil_domain" in parent.types: - # NeuronsPartHere query - q = NeuronsPartHere_to_schema(parent_label, {"short_form": parent_short_form}) - queries.append(q) - - # NeuronsSynaptic query - q = NeuronsSynaptic_to_schema(parent_label, {"short_form": parent_short_form}) - queries.append(q) - - # NeuronsPresynapticHere query - q = NeuronsPresynapticHere_to_schema(parent_label, {"short_form": parent_short_form}) - queries.append(q) - - # NeuronsPostsynapticHere query - q = NeuronsPostsynapticHere_to_schema(parent_label, {"short_form": parent_short_form}) - queries.append(q) - - # TractsNervesInnervatingHere query - q = TractsNervesInnervatingHere_to_schema(parent_label, {"short_form": parent_short_form}) - queries.append(q) - - # LineageClonesIn query - q = LineageClonesIn_to_schema(parent_label, {"short_form": parent_short_form}) - queries.append(q) - - # ImagesNeurons query - q = ImagesNeurons_to_schema(parent_label, {"short_form": parent_short_form}) - queries.append(q) - - if "Expression_pattern" in parent.types or "Expression_pattern_fragment" in parent.types: - # AnatomyExpressedIn query — anatomy classes where this - # expression pattern is expressed. - q = AnatomyExpressedIn_to_schema(parent_label, {"short_form": parent_short_form}) - queries.append(q) - - if "Anatomy" in parent.types and "hasScRNAseq" in parent.types: - # anatScRNAseqQuery query - q = anatScRNAseqQuery_to_schema(parent_label, {"short_form": parent_short_form}) - queries.append(q) - - if "Neuron_projection_bundle" in parent.types: - # NeuronClassesFasciculatingHere query - q = NeuronClassesFasciculatingHere_to_schema(parent_label, {"short_form": parent_short_form}) - queries.append(q) - - if "Neuroblast" in parent.types: - # ImagesThatDevelopFrom query - q = ImagesThatDevelopFrom_to_schema(parent_label, {"short_form": parent_short_form}) - queries.append(q) - - if "Expression_pattern" in parent.types: - # epFrag query - q = epFrag_to_schema(parent_label, {"short_form": parent_short_form}) - queries.append(q) - - if "Nervous_system" in parent.types and ("Anatomy" in parent.types or "Neuron" in parent.types): - # TransgeneExpressionHere query - q = TransgeneExpressionHere_to_schema(parent_label, {"short_form": parent_short_form}) - queries.append(q) - - # PartsOf query - for any Class - q = PartsOf_to_schema(parent_label, {"short_form": parent_short_form}) - queries.append(q) - - # SubclassesOf query - for any Class - q = SubclassesOf_to_schema(parent_label, {"short_form": parent_short_form}) - queries.append(q) - + # Bring the parent class's query menu down onto selected Individual + # instances, reproducing the legacy term-info builder + # (VFBProcessTermInfoCachedJson, gate ~line 1757): an Individual of one + # of these anatomical / expression-pattern types inherits every + # class-anchored query its parent class matches, run on the class (not + # the individual). Replaces an earlier Technique == "computer graphic" + # gate that only caught painted domains and silently dropped confocal + # instances (expression patterns, splits, tracts, muscle). + # + # The predicate table mirrors the class-menu conditions above; keep the + # two in sync (a future refactor should share a single source). + inherit_instance_types = ( + "Painted_domain", "Synaptic_neuropil", "Synaptic_neuropil_domain", + "Synaptic_neuropil_subdomain", "Neuron_projection_bundle", "Split", + "Expression_pattern", "Muscle", + ) + instance_super_types = termInfo["SuperTypes"] or [] + if termInfo["IsIndividual"] and any(t in instance_super_types for t in inherit_instance_types): + # Legacy anchored on the first parent Class (classVariable = parents[0]). + inherit_parent = next( + (p for p in (vfbTerm.parents or []) if p.types and "Class" in p.types), + None, + ) + if inherit_parent is not None: + p_types = set(inherit_parent.types or []) + p_ref = {"short_form": inherit_parent.short_form} + p_label = inherit_parent.label if inherit_parent.label else inherit_parent.short_form + # (schema builder, predicate over the parent class's type set). + # Predicates copy the Class-gated conditions used for the term's + # own menu above so an instance shows exactly what its class shows. + inheritable_class_queries = ( + (ListAllAvailableImages_to_schema, lambda p: {"Class", "Anatomy"} <= p), + (NeuronsPartHere_to_schema, lambda p: "Class" in p and "Neuron" not in p and ("Synaptic_neuropil" in p or "Anatomy" in p)), + (NeuronsSynaptic_to_schema, lambda p: "Class" in p and ("Synaptic_neuropil" in p or "Visual_system" in p or "Synaptic_neuropil_domain" in p)), + (NeuronsPresynapticHere_to_schema, lambda p: "Class" in p and ("Synaptic_neuropil" in p or "Visual_system" in p or "Synaptic_neuropil_domain" in p)), + (NeuronsPostsynapticHere_to_schema, lambda p: "Class" in p and ("Synaptic_neuropil" in p or "Visual_system" in p or "Synaptic_neuropil_domain" in p)), + (ImagesNeurons_to_schema, lambda p: "Class" in p and ("Synaptic_neuropil" in p or "Synaptic_neuropil_domain" in p)), + (TractsNervesInnervatingHere_to_schema, lambda p: "Class" in p and ("Synaptic_neuropil" in p or "Synaptic_neuropil_domain" in p)), + (LineageClonesIn_to_schema, lambda p: "Class" in p and ("Synaptic_neuropil" in p or "Synaptic_neuropil_domain" in p)), + (NeuronClassesFasciculatingHere_to_schema, lambda p: "Class" in p and "Neuron_projection_bundle" in p), + (ComponentsOf_to_schema, lambda p: {"Class", "Clone"} <= p), + (ImagesThatDevelopFrom_to_schema, lambda p: {"Class", "Neuroblast"} <= p), + (epFrag_to_schema, lambda p: {"Class", "Expression_pattern"} <= p), + (AnatomyExpressedIn_to_schema, lambda p: "Class" in p and ("Expression_pattern" in p or "Expression_pattern_fragment" in p)), + (anatScRNAseqQuery_to_schema, lambda p: {"Class", "Anatomy", "hasScRNAseq"} <= p), + (TransgeneExpressionHere_to_schema, lambda p: "Class" in p and "Nervous_system" in p and ("Anatomy" in p or "Neuron" in p)), + (TargetNeurons_to_schema, lambda p: {"Class", "Split"} <= p), + (DownstreamClassConnectivity_to_schema, lambda p: {"Class", "Neuron"} <= p), + (UpstreamClassConnectivity_to_schema, lambda p: {"Class", "Neuron"} <= p), + (PartsOf_to_schema, lambda p: "Class" in p and "Neuron" not in p), + (SubclassesOf_to_schema, lambda p: "Class" in p), + ) + for schema_fn, predicate in inheritable_class_queries: + if predicate(p_types): + queries.append(schema_fn(p_label, p_ref)) + # Add Publications to the termInfo object if vfbTerm.pubs and len(vfbTerm.pubs) > 0: publications = []