From 71951161bf9ee84d577a2a6005345745d5b790af Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Wed, 15 Jul 2026 16:59:14 -0400 Subject: [PATCH 1/3] Drop fully-identical duplicate edges before emission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Distinct virtual reactions of one curated reaction can canonicalize (union-find) to the same target node; a catalyst/regulator attached to each then produces exact-duplicate rows (same source, target, sign, type, reaction). The solver has no dedup — create_reaction_from_edges pushes each edge's parent into `activators` — so a duplicated catalyst is double-weighted in the activator aggregation (geomean/hill), skewing the reaction toward that parent. Add a final dedup pass on the edge list keyed by (source, target, pos_neg, and_or, edge_type, edge_reaction_id). Distinct reactions keep distinct edge_reaction_id, so only truly-redundant rows collapse. Scale: 39 of 92 catalog pathways affected; Pre-NOTCH 40,548 -> 27,552 edges (32% were duplicates), FGFR2 -7,806, ERBB2 -3,345, RAF -2,191. Co-Authored-By: Claude Opus 4.8 --- src/logic_network_generator.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/logic_network_generator.py b/src/logic_network_generator.py index 94b16ca..25e429e 100755 --- a/src/logic_network_generator.py +++ b/src/logic_network_generator.py @@ -1833,6 +1833,31 @@ def create_pathway_logic_network( entity_uuid_registry=entity_uuid_registry, ) + # Drop fully-identical duplicate edges. Distinct virtual reactions of one + # curated reaction can canonicalize (union-find) to the same target node, and + # a catalyst/regulator attached to each then yields exact-duplicate rows + # (same source, target, sign, type, reaction). These are redundant — a + # catalyst does not catalyze the same reaction twice — and the solver would + # double-weight the duplicated parent in its activator aggregation. Collapse + # to one row each. (Observed: Pre-NOTCH 40,548 -> 27,552 edges; 39 pathways + # affected across the catalog.) + _pre_dedup = len(pathway_logic_network_data) + _seen_edges: Set[tuple] = set() + _deduped: List[Dict[str, Any]] = [] + for _e in pathway_logic_network_data: + _k = (_e["source_id"], _e["target_id"], _e["pos_neg"], + _e["and_or"], _e["edge_type"], _e.get("edge_reaction_id", "")) + if _k in _seen_edges: + continue + _seen_edges.add(_k) + _deduped.append(_e) + if len(_deduped) < _pre_dedup: + logger.info( + f"Dropped {_pre_dedup - len(_deduped)} duplicate edges " + f"({_pre_dedup} -> {len(_deduped)})" + ) + pathway_logic_network_data = _deduped + # Create final DataFrame pathway_logic_network = pd.DataFrame(pathway_logic_network_data, columns=list(columns.keys())) # Coerce stoichiometry to nullable Int64 — emission sites use a mix of From 6cecdb53819b5ada3c5ee750e1acd13023866afe Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Wed, 15 Jul 2026 18:32:50 -0400 Subject: [PATCH 2/3] Bundle catalyst/positive-regulator complexes to single nodes (no subunit shatter) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalysts and positive regulators were decomposed to their individual subunit proteins, making each protein a SHARED node wired directly to every reaction its complexes catalyse — a super-hub that lets signal flood unrelated reactions and is inconsistent with the complex-as-node treatment of reaction inputs. Now they use bundle_complex mode in _decompose_regulator_entity: a complex is ONE node regardless of internal sets (no subunit shatter, and no cartesian set-variant expansion either — variant expansion blew TP53 up to 156k edges). Only genuine top-level "one-of" EntitySets still expand into OR alternatives. Negative regulators keep variant decomposition as before. Env toggle LNG_CATALYST_SUBUNITS=1 restores the old subunit shatter for A/B. Effect (TP53): 18,082 -> 5,631 edges (69% fewer) — its transcription catalysts were heavily shattered. (Does NOT by itself fix DAXX over-reach, which is network-wide shared-node coupling + undamped propagation, a separate issue.) Co-Authored-By: Claude Opus 4.8 --- src/logic_network_generator.py | 46 +++++++++++++++++--------- tests/test_regulators_and_catalysts.py | 2 +- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/logic_network_generator.py b/src/logic_network_generator.py index 25e429e..b3bfa8b 100755 --- a/src/logic_network_generator.py +++ b/src/logic_network_generator.py @@ -753,6 +753,7 @@ def _annotated(reaction_id: str, io: str) -> Set[str]: def _decompose_regulator_entity( entity_id: str, variant_decomposition: bool = False, + bundle_complex: bool = False, ) -> List[tuple]: """Decompose a catalyst/regulator entity to (terminal_id, stoichiometry) pairs. @@ -785,7 +786,12 @@ def _decompose_regulator_entity( labels = get_labels(entity_id) if "Complex" in labels: - if not _complex_contains_entity_set(entity_id): + # bundle_complex: a catalyst/regulator complex is ONE node regardless of + # internal sets — no subunit shatter (shared-protein hubs) and no + # cartesian set-variant expansion (edge blow-up). This is the strict + # complex-as-node treatment; only genuine top-level "one-of" sets below + # still expand into OR alternatives. + if bundle_complex or not _complex_contains_entity_set(entity_id): return [(entity_id, 1)] if variant_decomposition: return _expand_complex_variants(entity_id) @@ -793,7 +799,8 @@ def _decompose_regulator_entity( result = [] for member_id, stoich in components.items(): for mid, sub_stoich in _decompose_regulator_entity( - member_id, variant_decomposition=variant_decomposition + member_id, variant_decomposition=variant_decomposition, + bundle_complex=bundle_complex, ): result.append((mid, stoich * sub_stoich)) return result if result else [(entity_id, 1)] @@ -806,7 +813,8 @@ def _decompose_regulator_entity( for member_id in members: result.extend( _decompose_regulator_entity( - member_id, variant_decomposition=variant_decomposition + member_id, variant_decomposition=variant_decomposition, + bundle_complex=bundle_complex, ) ) return result if result else [(entity_id, 1)] @@ -1454,25 +1462,31 @@ def append_regulators( ] for map_df, pos_neg, edge_type in regulator_configs: - # Negative regulators use VARIANT decomposition: an inhibitor complex - # with an internal EntitySet expands into one entity per cartesian - # variant of that EntitySet, but each variant is kept as a single - # complex — NOT broken down into individual subunits. Without this, - # HSP90 / CDC37 / ERBIN of an ERBB2 inhibitor complex would each - # become standalone inhibitor edges, and any unrelated reaction - # producing those bystander proteins would spuriously crush - # downstream signal. - # - # Catalysts and positive regulators keep SUBUNIT decomposition: each - # holoenzyme subunit is biologically AND-required for catalysis, so - # decomposing to terminal proteins is correct there. + # ALL regulators (catalysts, positive and negative) use VARIANT + # decomposition: a complex is kept as a single node (split only into one + # node per internal-EntitySet variant), NEVER broken into its individual + # subunit proteins. This matches the complex-as-node treatment of + # reaction inputs. Subunit decomposition was previously used for + # catalysts/positive regulators, but it made each subunit protein a + # SHARED node wired directly to every reaction its complexes catalyse — + # a super-hub that let signal flood unrelated reactions (e.g. DAXX + # reaching ~2,100 nodes in TP53). A holoenzyme is one biological unit; + # a subunit knockout still propagates via the boundary assembly edges + # (subunit → complex), without the subunit being a direct catalytic hub. + # Env toggle LNG_CATALYST_SUBUNITS=1 restores the old subunit-shatter for + # A/B. Default: catalysts + positive regulators are BUNDLED (complex = + # one node); negative regulators keep VARIANT decomposition (one node per + # set-variant, complex still whole) as before. + old_subunits = os.environ.get("LNG_CATALYST_SUBUNITS", "0") == "1" variant_decomposition = (pos_neg == "neg") + bundle_complex = (pos_neg == "pos") and not old_subunits for _, row in map_df.iterrows(): entity_id = str(row["entity_id"]) terminal_members = _decompose_regulator_entity( - entity_id, variant_decomposition=variant_decomposition + entity_id, variant_decomposition=variant_decomposition, + bundle_complex=bundle_complex, ) # and_or expresses reaction-level requirement, not within-entity diff --git a/tests/test_regulators_and_catalysts.py b/tests/test_regulators_and_catalysts.py index 8885ff7..19970c1 100644 --- a/tests/test_regulators_and_catalysts.py +++ b/tests/test_regulators_and_catalysts.py @@ -24,7 +24,7 @@ from src.logic_network_generator import append_regulators -def _mock_decompose(entity_id, variant_decomposition=False): +def _mock_decompose(entity_id, variant_decomposition=False, bundle_complex=False): """Return entity as-is (no decomposition) for unit tests.""" return [(entity_id, 1)] From ee1fdad57f28acbd4209010241c35f3a3cdc2050 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Wed, 15 Jul 2026 19:42:52 -0400 Subject: [PATCH 3/3] Default catalyst handling to subunit decomposition; bundle behind LNG_CATALYST_BUNDLE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalyst/positive-regulator bundling (the faithful complex-as-node representation) is more correct but OFF BY DEFAULT: it exposes a pre-existing UUID-silo defect (a complex produced by one reaction and catalysing/regulating another is stored as separate, un-unified nodes — compounded by set-variant mismatches and non-deterministic set-member selection), so the curated produce→catalyse chain doesn't carry signal. Subunit decomposition accidentally masks this via spurious shared-protein hub paths and scores ~1.2pp higher on the experimental set — not because it's more correct, but because the hubs reconnect what the silo severs. Until the silo × set-variant entity-identity problem is redesigned, subunit decomposition remains the default; bundling is opt-in via LNG_CATALYST_BUNDLE=1 for when that work happens. Co-Authored-By: Claude Opus 4.8 --- src/logic_network_generator.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/logic_network_generator.py b/src/logic_network_generator.py index b3bfa8b..87b50f3 100755 --- a/src/logic_network_generator.py +++ b/src/logic_network_generator.py @@ -1466,20 +1466,23 @@ def append_regulators( # decomposition: a complex is kept as a single node (split only into one # node per internal-EntitySet variant), NEVER broken into its individual # subunit proteins. This matches the complex-as-node treatment of - # reaction inputs. Subunit decomposition was previously used for - # catalysts/positive regulators, but it made each subunit protein a - # SHARED node wired directly to every reaction its complexes catalyse — - # a super-hub that let signal flood unrelated reactions (e.g. DAXX - # reaching ~2,100 nodes in TP53). A holoenzyme is one biological unit; - # a subunit knockout still propagates via the boundary assembly edges - # (subunit → complex), without the subunit being a direct catalytic hub. - # Env toggle LNG_CATALYST_SUBUNITS=1 restores the old subunit-shatter for - # A/B. Default: catalysts + positive regulators are BUNDLED (complex = - # one node); negative regulators keep VARIANT decomposition (one node per - # set-variant, complex still whole) as before. - old_subunits = os.environ.get("LNG_CATALYST_SUBUNITS", "0") == "1" + # reaction inputs and is the biologically faithful representation. + # + # HOWEVER it is OFF BY DEFAULT (opt-in via LNG_CATALYST_BUNDLE=1). Bundling + # is more faithful, but it EXPOSES a pre-existing UUID-silo defect: a + # complex that is produced by one reaction and catalyses/regulates another + # is stored as separate, un-unified nodes (compounded by set-variant + # mismatches and non-deterministic set-member selection), so the curated + # "produce → catalyse" chain doesn't carry signal (e.g. ATM→p-p53:FAS-gene + # →FAS expression breaks). Subunit decomposition accidentally MASKS this + # via spurious shared-protein hub paths, so it scores ~1.2pp higher on the + # experimental set — not because it's more correct, but because the hubs + # 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. + bundle_on = os.environ.get("LNG_CATALYST_BUNDLE", "0") == "1" variant_decomposition = (pos_neg == "neg") - bundle_complex = (pos_neg == "pos") and not old_subunits + bundle_complex = (pos_neg == "pos") and bundle_on for _, row in map_df.iterrows(): entity_id = str(row["entity_id"])