From 384ed35a33b05796c16af35312426c8f281ac5a6 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Wed, 15 Jul 2026 21:07:56 -0400 Subject: [PATCH] Strict complex-as-node: fix the catalyst/regulator UUID silo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A biological complex that is PRODUCED by one reaction and CATALYSES/REGULATES another was stored as separate, un-unified UUID nodes, severing the curated "complex produced -> then it catalyses/regulates" chain (traced: ATM -> active p53 -> p53:FAS-gene complex R-HSA-6799810 -> regulates FAS expression -> FAS was broken; ATM reached ~2000 nodes but not FAS). Two root causes, both specific to complexes-that-contain-an-internal-EntitySet: (1) the Phase-2 union-find only merges output@A<->input@B, never catalyst/regulator roles; (2) a complex-with-set got three DIFFERENT node-id encodings across produced / bundled-catalyst / negative-regulator paths, so the strings never matched to unify. Fix (LNG_COMPLEX_AS_NODE, default on): a Complex is ONE node = its plain stId regardless of internal EntitySets, in both emission encoders (_map_annotated_entity_to_nodes and _decompose_regulator_entity for all three regulator roles). All roles now agree on the plain stId, so produced and regulator occurrences unify. Purely an EMISSION change — the matching layer (reaction_generator cartesian) is untouched. Bare EntitySets still expand to OR members. The ::variant:: machinery is left in place, reachable via LNG_COMPLEX_AS_NODE=0 for A/B. Members stay perturbable via their production reactions (a family member is an input to the binding reaction that makes the complex), so no distinction is lost where it is a real reaction participant. The connected regulators then combine via the existing multiplicative/hill_log activator aggregation. Verified vs Neo4j: R-HSA-6799810 collapses 3 nodes -> 1; ATM/BRCA1/CHEK2/MDM2 all reach FAS. Benchmarks (tuned cutoffs, DS_KO_REMAP): experimental 66.43% -> 69.85% (RAF +36, WNT +20, Mitotic_Prophase +15, S_Phase +8, ERBB2 +2, nothing regressed); curator 81.18% -> 83.82%. Best result of the series, zero regressions. TestComplexAsNode added; suite green, ruff + mypy clean. Co-Authored-By: Claude Opus 4.8 --- src/logic_network_generator.py | 17 +++++++++++++++-- tests/test_logic_network_generator.py | 27 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/logic_network_generator.py b/src/logic_network_generator.py index 87b50f3..7406beb 100755 --- a/src/logic_network_generator.py +++ b/src/logic_network_generator.py @@ -656,6 +656,14 @@ def _map_annotated_entity_to_nodes(entity_id: str, member_set: Set[str]) -> Set[ if "Complex" in labels: if not _complex_contains_entity_set(entity_id): return {str(entity_id)} # simple complex → single bundled node + # Strict complex-as-node (LNG_COMPLEX_AS_NODE): a Complex is ONE node = + # its plain stId even when it contains an internal EntitySet. This makes + # the produced-occurrence node id match the catalyst/regulator node id + # (all plain stId), so a complex produced by one reaction and + # catalysing/regulating another unifies instead of siloing into + # disconnected ::variant:: nodes. See plan luminous-percolating-llama. + if os.environ.get("LNG_COMPLEX_AS_NODE", "1") != "0": + return {str(entity_id)} # Set-variant node: OUTERMOST complex stId + a FLAT sorted list of the # variant's FULL terminal membership. We enumerate the complex's true # variants (each with complete membership) and pick the one this VR @@ -1480,9 +1488,14 @@ def append_regulators( # happen to reconnect what the silo severs. Until the silo × set-variant # entity-identity problem is redesigned, subunit decomposition stays the # default. See memory project_uuid_silo_bug / the catalyst-handling notes. + # Strict complex-as-node: bundle EVERY regulator complex (catalyst, pos, + # neg) to its plain stId so it matches the produced/consumed occurrence's + # node id and unifies instead of siloing. Subsumes the older opt-in + # LNG_CATALYST_BUNDLE (pos-only). + complex_as_node = os.environ.get("LNG_COMPLEX_AS_NODE", "1") != "0" bundle_on = os.environ.get("LNG_CATALYST_BUNDLE", "0") == "1" - variant_decomposition = (pos_neg == "neg") - bundle_complex = (pos_neg == "pos") and bundle_on + variant_decomposition = (pos_neg == "neg") and not complex_as_node + bundle_complex = complex_as_node or ((pos_neg == "pos") and bundle_on) for _, row in map_df.iterrows(): entity_id = str(row["entity_id"]) diff --git a/tests/test_logic_network_generator.py b/tests/test_logic_network_generator.py index 719b529..c5af256 100644 --- a/tests/test_logic_network_generator.py +++ b/tests/test_logic_network_generator.py @@ -743,3 +743,30 @@ def test_complex_with_internal_set_decomposes(self, monkeypatch): assert result == frozenset({"A", "B", "P"}), ( f"complex-with-set must decompose to members, got {set(result)}" ) + + +class TestComplexAsNode: + """LNG_COMPLEX_AS_NODE (default on): a Complex is ONE node = its plain stId + even when it contains an internal EntitySet, so the produced-complex node id + matches the catalyst/regulator node id and they unify (fixes the UUID silo + that severed produce->catalyze chains, e.g. ATM->p53:FAS-gene complex->FAS).""" + + def test_complex_with_set_emits_plain_stid(self, monkeypatch): + import src.logic_network_generator as m + from src import neo4j_connector as nc + monkeypatch.setattr(nc, "get_labels", lambda e: ["Complex"] if e == "C" else ["EntitySet"]) + monkeypatch.setattr(m, "_complex_contains_entity_set", lambda e: True) + # default-on: complex-with-set -> plain stId, never a ::variant:: node + result = m._map_annotated_entity_to_nodes("C", {"C", "A", "B"}) + assert result == {"C"}, f"complex-as-node must emit plain stId, got {result}" + + def test_flag_off_restores_variant_behavior(self, monkeypatch): + import src.logic_network_generator as m + from src import neo4j_connector as nc + monkeypatch.setenv("LNG_COMPLEX_AS_NODE", "0") + monkeypatch.setattr(nc, "get_labels", lambda e: ["Complex"]) + monkeypatch.setattr(m, "_complex_contains_entity_set", lambda e: True) + monkeypatch.setattr(m, "_complex_variant_leafsets", lambda e: [frozenset({"A", "B"})]) + m._variant_capped.discard("C") + result = m._map_annotated_entity_to_nodes("C", {"A", "B"}) + assert any("::variant::" in r for r in result), f"flag off must variant-split, got {result}"