Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/logic_network_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
_complex_contains_entity_set,
_UBIQUITIN_ENTITY_SET_IDS,
get_terminal_components,
MAX_VARIANTS,
)


Expand Down Expand Up @@ -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::<all-leaves>`` id.
_variant_capped: Set[str] = set()


def _complex_variant_leafsets(complex_id: str) -> List[frozenset]:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
42 changes: 35 additions & 7 deletions src/reaction_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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] = []
Expand Down
53 changes: 53 additions & 0 deletions tests/test_decomposition_semantics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
40 changes: 40 additions & 0 deletions tests/test_logic_network_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading