diff --git a/src/logic_network_generator.py b/src/logic_network_generator.py index 544e6c1..414e592 100755 --- a/src/logic_network_generator.py +++ b/src/logic_network_generator.py @@ -11,6 +11,7 @@ _complex_contains_entity_set, _UBIQUITIN_ENTITY_SET_IDS, get_terminal_components, + MAX_VARIANTS, ) @@ -520,6 +521,10 @@ def _parse_variant_members(variant_id: str) -> Set[str]: _variant_leafsets_cache: Dict[str, List[frozenset]] = {} +# Complexes whose set-variant expansion exceeded MAX_VARIANTS (issue #40) and were +# therefore bundled into one opaque node. Emission uses this to name them by their +# plain stId rather than a giant ``::variant::`` id. +_variant_capped: Set[str] = set() def _complex_variant_leafsets(complex_id: str) -> List[frozenset]: @@ -557,6 +562,23 @@ def _complex_variant_leafsets(complex_id: str) -> List[frozenset]: else: per_component_choices.append([frozenset(get_terminal_components(member_id))]) + cartesian_size = 1 + for choices in per_component_choices: + cartesian_size *= max(1, len(choices)) + if MAX_VARIANTS > 0 and cartesian_size > MAX_VARIANTS: + # Too many variants (issue #40): bundle the whole complex into one + # opaque leaf-set rather than enumerating the product. Mirrors the + # matching-layer cap in reaction_generator.get_broken_apart_ids so both + # layers agree this complex is a single node. + logger.warning( + f"variant cap hit for complex {complex_id}: {cartesian_size} " + f"variants > {MAX_VARIANTS}; bundling into one node" + ) + _variant_capped.add(complex_id) + result = [frozenset(get_terminal_components(complex_id))] + _variant_leafsets_cache[complex_id] = result + return result + variants: List[frozenset] = [] seen: Set[frozenset] = set() for combo in itertools.product(*per_component_choices): @@ -599,6 +621,9 @@ def _map_annotated_entity_to_nodes(entity_id: str, member_set: Set[str]) -> Set[ # emitting spurious partial variants missing a subunit. Id is kept flat # so the parent is always ``id.split("::variant::")[0]``. variant_leafsets = _complex_variant_leafsets(entity_id) + if entity_id in _variant_capped: + # Over the variant cap (issue #40): opaque bundle, named by stId. + return {str(entity_id)} subset = [ls for ls in variant_leafsets if ls <= member_set] if subset: chosen = max(subset, key=len) @@ -786,6 +811,22 @@ def _expand_complex_variants(complex_id: str) -> List[tuple]: else: per_component_choices.append([member_id]) + cartesian_size = 1 + for choices in per_component_choices: + cartesian_size *= max(1, len(choices)) + if MAX_VARIANTS > 0 and cartesian_size > MAX_VARIANTS: + # Too many inhibitor-complex variants (issue #40): bundle into one + # opaque node rather than materializing the product. Without this a + # negative-regulator complex with large internal EntitySets fans out + # into hundreds of thousands of variant nodes, each wired to every + # reaction it inhibits (RAF/MAP kinase: 4.76M regulator edges from + # ~144k variant nodes). Mirrors the input/output caps. + logger.warning( + f"variant cap hit for regulator complex {complex_id}: " + f"{cartesian_size} variants > {MAX_VARIANTS}; bundling into one node" + ) + return [(complex_id, 1)] + variants: List[tuple] = [] seen_variant_ids: set = set() for combo in itertools.product(*per_component_choices): diff --git a/src/reaction_generator.py b/src/reaction_generator.py index 553088a..1329e20 100755 --- a/src/reaction_generator.py +++ b/src/reaction_generator.py @@ -28,6 +28,7 @@ import hashlib import itertools +import os import uuid import warnings from typing import Any, Dict, List, Optional, Sequence, Set, Tuple, Union @@ -60,6 +61,15 @@ ReactomeID = str DataFrameRow = Dict[str, Any] +# Cap on set-variant cartesian expansion (issue #40). When a complex's internal +# EntitySet combinations — or a reaction's input/output combinations — would +# exceed this, we bundle all alternatives into ONE opaque node instead of +# materializing the full product. This prevents catalog-scale explosions +# (Class_I_MHC = 3.3M edges, Chromatin_modifying_enzymes = 1.4M) and the hangs +# they cause, at the cost of not set-splitting a handful of pathologically large +# assemblies. Env-tunable via LNG_MAX_VARIANTS; <= 0 disables the cap. +MAX_VARIANTS = int(os.environ.get("LNG_MAX_VARIANTS", "512")) + class _DecompositionStore: """Append-mostly buffer for decomposition rows with O(1) lookups. @@ -216,13 +226,31 @@ def get_broken_apart_ids( else: new_broken_apart_members.append({member}) - iterproduct_components = list(itertools.product(*new_broken_apart_members)) - iterproduct_components_as_sets = [ - set(map(str, item)) for item in iterproduct_components - ] - uids = get_uids_for_iterproduct_components( - iterproduct_components_as_sets, reactome_id, source_entity_id - ) + cartesian_size = 1 + for member in new_broken_apart_members: + cartesian_size *= max(1, len(member)) + if MAX_VARIANTS > 0 and cartesian_size > MAX_VARIANTS: + # Too many set-variant combinations (issue #40): bundle every + # alternative into a single opaque node rather than exploding. + merged: Set[str] = set() + for member in new_broken_apart_members: + merged |= member + logger.warning( + f"variant cap hit for {reactome_id}: {cartesian_size} " + f"combinations > {MAX_VARIANTS}; bundling " + f"{len(new_broken_apart_members)} components into one node" + ) + uids = get_uids_for_iterproduct_components( + [merged], reactome_id, source_entity_id + ) + else: + iterproduct_components = list(itertools.product(*new_broken_apart_members)) + iterproduct_components_as_sets = [ + set(map(str, item)) for item in iterproduct_components + ] + uids = get_uids_for_iterproduct_components( + iterproduct_components_as_sets, reactome_id, source_entity_id + ) else: uid = str(uuid.uuid4()) rows: List[DataFrameRow] = [] diff --git a/tests/test_decomposition_semantics.py b/tests/test_decomposition_semantics.py index 2b8218d..56bdc61 100644 --- a/tests/test_decomposition_semantics.py +++ b/tests/test_decomposition_semantics.py @@ -189,6 +189,59 @@ def test_complex_with_entityset_writes_rows_per_combination(self): assert set(rows_for_c["component_id"]) == {"A", "B", "P"} +class TestVariantCap: + """A complex whose set-variant cartesian exceeds MAX_VARIANTS is bundled + into ONE opaque node instead of exploding (issue #40).""" + + def test_over_cap_complex_bundles_to_single_node(self): + # Complex with two EntitySets of 3 members each → 3×3 = 9 combinations. + # With the cap set to 4, that exceeds it and must collapse to one node. + labels = _label_map({ + "C": ["Complex"], + "S1": ["EntitySet"], "S2": ["EntitySet"], + "A": ["GenomeEncodedEntity"], "B": ["GenomeEncodedEntity"], + "D": ["GenomeEncodedEntity"], "E": ["GenomeEncodedEntity"], + "F": ["GenomeEncodedEntity"], "G": ["GenomeEncodedEntity"], + }) + components = _components_map({"C": {"S1": 1, "S2": 1}}) + members = _members_map({"S1": ["A", "B", "D"], "S2": ["E", "F", "G"]}) + with patch.object(rg, "MAX_VARIANTS", 4), \ + patch.object(rg, "get_labels", labels), \ + patch.object(rg, "get_complex_components", components), \ + patch.object(rg, "get_set_members", members), \ + patch.object(rg, "get_reference_entity_id", _ref_entity_map({})): + result = rg.break_apart_entity("C") + + assert len(result) == 1, ( + f"9 combinations > cap 4 must bundle to ONE node, got {len(result)}" + ) + # The one bundled node still carries every alternative's components. + df = _store_df() + rows_for_c = df[df["reactome_id"] == "C"] + assert set(rows_for_c["component_id"]) == {"A", "B", "D", "E", "F", "G"} + + def test_under_cap_complex_still_expands(self): + # Same shape but cap high enough (9) → full cartesian preserved. + labels = _label_map({ + "C": ["Complex"], + "S1": ["EntitySet"], "S2": ["EntitySet"], + "A": ["GenomeEncodedEntity"], "B": ["GenomeEncodedEntity"], + "D": ["GenomeEncodedEntity"], "E": ["GenomeEncodedEntity"], + "F": ["GenomeEncodedEntity"], "G": ["GenomeEncodedEntity"], + }) + components = _components_map({"C": {"S1": 1, "S2": 1}}) + members = _members_map({"S1": ["A", "B", "D"], "S2": ["E", "F", "G"]}) + with patch.object(rg, "MAX_VARIANTS", 100), \ + patch.object(rg, "get_labels", labels), \ + patch.object(rg, "get_complex_components", components), \ + patch.object(rg, "get_set_members", members), \ + patch.object(rg, "get_reference_entity_id", _ref_entity_map({})): + result = rg.break_apart_entity("C") + assert len(result) == 9, ( + f"3×3 cartesian under cap should yield 9 combinations, got {len(result)}" + ) + + class TestUbiquitinIsAtomic: """Ubiquitin EntitySets are skipped to avoid combinatorial explosion.""" diff --git a/tests/test_logic_network_generator.py b/tests/test_logic_network_generator.py index 87cc03a..395b982 100644 --- a/tests/test_logic_network_generator.py +++ b/tests/test_logic_network_generator.py @@ -650,3 +650,43 @@ def test_reaction_not_in_network_yields_no_row(self, tmp_path): entity_reactions={"R-HSA-C": {"output": ["R-HSA-MISSING"]}}, ) assert df.empty or "R-HSA-C" not in set(df["entity_stable_id"]) + + +class TestRegulatorVariantCap: + """_expand_complex_variants (the negative-regulator decomposition path) + must bundle a complex into ONE opaque node when its set-variant cartesian + exceeds MAX_VARIANTS (issue #40). Without this a single inhibitor complex + with large internal EntitySets fans out into ~144k variant nodes, each + wired to every reaction it inhibits — RAF/MAP kinase hit 4.76M edges. + """ + + def _wire_mocks(self, monkeypatch): + import src.logic_network_generator as m + from src import neo4j_connector as nc + # Complex C = two EntitySets of 3 members each -> 3x3 = 9 variants. + monkeypatch.setattr(nc, "get_labels", lambda e: { + "C": ["Complex"], "S1": ["EntitySet"], "S2": ["EntitySet"], + }.get(e, ["EntityWithAccessionedSequence"])) + monkeypatch.setattr(nc, "get_complex_components", + lambda e: {"S1": 1, "S2": 1} if e == "C" else {}) + monkeypatch.setattr(nc, "get_set_members", lambda e: { + "S1": ["A", "B", "D"], "S2": ["E", "F", "G"]}.get(e, [])) + monkeypatch.setattr(m, "_complex_contains_entity_set", lambda e: e == "C") + return m + + def test_over_cap_bundles_to_single_node(self, monkeypatch): + m = self._wire_mocks(monkeypatch) + monkeypatch.setattr(m, "MAX_VARIANTS", 4) + result = m._expand_complex_variants("C") + assert result == [("C", 1)], ( + f"9 variants > cap 4 must bundle to one node, got {result}" + ) + + def test_under_cap_still_expands(self, monkeypatch): + m = self._wire_mocks(monkeypatch) + monkeypatch.setattr(m, "MAX_VARIANTS", 100) + result = m._expand_complex_variants("C") + assert len(result) == 9, ( + f"3x3 cartesian under cap should yield 9 variants, got {len(result)}" + ) + assert all(vid.startswith("C::variant::") for vid, _ in result)