diff --git a/PIPELINE_SETTINGS.md b/PIPELINE_SETTINGS.md index 6d3fb68..b511f57 100644 --- a/PIPELINE_SETTINGS.md +++ b/PIPELINE_SETTINGS.md @@ -76,6 +76,7 @@ what NOT to touch. Overriding is for A/B benchmarking only. | `LNG_RELEASE_POOLS` | `1` | a protein bound, modified and released by a complex, whose released copy carries on (beta-catenin and the destruction complex), as a free/bound pool whose released copy reads the free state; writes `released_twin` and `recycle` edges (deltasignal specs/086) | | `LNG_UB_LIGASE_UNIT` | `1` | a ubiquitination does not deplete its own ligase: a leaf of the catalyst's curated `activeUnit`, or of a complex released unchanged, that leaves the reaction unmodified is not the substrate (CKS1B under SCF(Skp2); deltasignal specs/089) | | `LNG_BALANCE_CHOICES` | `1` | drop a reaction copy whose chosen set members do not balance (its outputs gain, or its inputs lose, proteins) when better-balanced copies exist with the same outputs and with the same input choice: MDM2 alone no longer makes MDM2:MDM4, p-STAT6 no longer makes the STAT3 dimer (deltasignal specs/101) | +| `LNG_SUMMATION_REGULATIONS` | `1` | a curation supplement: three regulations Reactome states in a reaction's summation but not in its event graph, as structural rules in pathways holding the source reaction (RB1:E2F inhibits E2F1/2/3-stimulated transcription; AKT's p-T-p21/p27 inhibits Cip/Kip binding to cyclin:CDK; the p-Y-Cip/Kip CDK4/6 complex activates CDK4/6 catalysis). Each regulator is one pool of its variants. `0` builds Release 97 alone (deltasignal specs/102) | | `LNG_SPECIALIZATION_BRIDGE` | `add` | join a generic complex of sets and the specific member combination Reactome curates under another id, ordered by `precedingEvent` (`specialization` OR edges; `replace` \| `off`; deltasignal specs/067) | | `LNG_SPECIALIZATION_CLOSED` | `twin` | a specialization in-edge does not open a carrier loop (`off`; deltasignal specs/068) | | `LNG_CATALYST_BUNDLE` | `0` | legacy; subsumed by `LNG_COMPLEX_AS_NODE` | diff --git a/src/env_flags.py b/src/env_flags.py index 84aeb35..5fb972c 100644 --- a/src/env_flags.py +++ b/src/env_flags.py @@ -15,6 +15,7 @@ "LNG_BOUNDARY_EXPANSION": True, "LNG_BOUNDARY_HIERARCHY": True, "LNG_BALANCE_CHOICES": True, # default since deltasignal specs/101 (held-out +30, exp -4; MDM4 KO inverts) + "LNG_SUMMATION_REGULATIONS": True, # default since deltasignal specs/102 (a curation supplement: exp +6, tuning +173, held-out 0) "LNG_BIND_STOICH": True, # default since deltasignal specs/048 (homodimer binding; 0 predictions moved) "LNG_CAP_POOLS": True, # default since deltasignal specs/045 (RAF experimental +14, held-out 0) "LNG_CATALYST_BUNDLE": False, diff --git a/src/logic_network_generator.py b/src/logic_network_generator.py index 602824b..dd4bab8 100755 --- a/src/logic_network_generator.py +++ b/src/logic_network_generator.py @@ -2915,6 +2915,18 @@ def create_pathway_logic_network( catalyst_map = get_catalysts_for_reaction(reaction_id_map, graph) negative_regulator_map = get_negative_regulators_for_reaction(reaction_id_map, graph) positive_regulator_map = get_positive_regulators_for_reaction(reaction_id_map, graph) + if env_flag("LNG_SUMMATION_REGULATIONS"): + from src.summation_regulations import summation_regulations + regs = summation_regulations(graph, list(reaction_id_map["reactome_id"].dropna().astype(str).unique())) + extra: Dict[str, List[dict]] = {"pos": [], "neg": []} + for _, row in reaction_id_map.iterrows(): + for sign, ids in regs.get(str(row["reactome_id"]), {}).items(): + for e in ids: + extra[sign].append({"reaction_id": row["reactome_id"], "entity_id": e, + "edge_type": "regulator", "uuid": str(uuid.uuid4()), + "reaction_uuid": row["uid"]}) + positive_regulator_map = pd.concat([positive_regulator_map, pd.DataFrame(extra["pos"], columns=_CAT_REG_COLUMNS)], ignore_index=True) + negative_regulator_map = pd.concat([negative_regulator_map, pd.DataFrame(extra["neg"], columns=_CAT_REG_COLUMNS)], ignore_index=True) # Print regulator statistics _print_regulator_statistics(positive_regulator_map, negative_regulator_map, catalyst_map) diff --git a/src/pathway_generator.py b/src/pathway_generator.py index 0e02acd..675b8c9 100755 --- a/src/pathway_generator.py +++ b/src/pathway_generator.py @@ -52,6 +52,7 @@ "LNG_POOL_SHARED", "LNG_BIND_STOICH", "LNG_BALANCE_CHOICES", + "LNG_SUMMATION_REGULATIONS", "LNG_PATHOGEN_PROTEIN", "LNG_VARIANT_NODES", "LNG_DIAGRAM_SET_MEMBER", diff --git a/src/summation_regulations.py b/src/summation_regulations.py new file mode 100644 index 0000000..48ce785 --- /dev/null +++ b/src/summation_regulations.py @@ -0,0 +1,72 @@ +"""LNG_SUMMATION_REGULATIONS (deltasignal specs/102): regulations a reaction's summation states. + +Three Release 97 reactions in the cell cycle make a product no reaction uses, while +their summations say what it does (specs/101 T1, T3). Each rule below writes that +product as a regulator of the reactions the summation names, chosen by structure, +and only inside a pathway that holds both the source reaction and the target. + +- E1: RB1:E2F (R-HSA-9018017's product) inhibits every reaction positively + regulated by an E2F1/2/3 entity. "RB1 binding inhibits transcriptional + activity of E2F1/2/3:TFDP1/2 complexes." +- E2: AKT's p-T-CDKN1A/B (R-HSA-198613) inhibits every binding of CDKN1A/B to a + CDK2/4/6 complex. "retention in the cytoplasm, segregating these CDK + inhibitors from cyclin-CDK complexes." +- E3: CDK4/6:CCND:p-Y-Cip/Kip (R-HSA-8942607) activates every reaction catalysed + by CDK4/6 with no other active unit. "increased catalytic activity of CDK4 or + CDK6." + +This is a curation supplement to the release, not the release. On by default since +specs/102 (Adam, 2026-10-24); `LNG_SUMMATION_REGULATIONS=0` builds the release alone. +""" +from collections import Counter +from typing import Dict, List, Sequence + +STATS: Counter = Counter() + +_HOLDS = "-[:hasComponent|hasMember|hasCandidate*0..]->(:EntityWithAccessionedSequence)-[:referenceEntity]->" + +# (id, source reaction, regulator, sign, Cypher returning target stIds among $rids) +RULES = [ + ("E1", "R-HSA-9018017", "R-HSA-68644", "neg", + "UNWIND $rids AS rid MATCH (r:ReactionLikeEvent {stId: rid})" + "-[:regulatedBy]->(:PositiveRegulation)-[:regulator]->(reg) " + f"MATCH (reg){_HOLDS}(g:ReferenceGeneProduct) WHERE g.geneName[0] IN ['E2F1', 'E2F2', 'E2F3'] " + "RETURN DISTINCT r.stId AS target"), + ("E2", "R-HSA-198613", "R-HSA-198605", "neg", + "UNWIND $rids AS rid MATCH (r:ReactionLikeEvent {stId: rid})-[:input]->(a) " + f"MATCH (a){_HOLDS}(ga:ReferenceGeneProduct) WHERE ga.geneName[0] IN ['CDKN1A', 'CDKN1B'] " + "MATCH (r)-[:input]->(b) WHERE b <> a " + f"MATCH (b){_HOLDS}(gb:ReferenceGeneProduct) WHERE gb.geneName[0] IN ['CDK2', 'CDK4', 'CDK6'] " + "RETURN DISTINCT r.stId AS target"), + ("E3", "R-HSA-8942607", "R-HSA-8942622", "pos", + "UNWIND $rids AS rid MATCH (r:ReactionLikeEvent {stId: rid})" + "-[:catalystActivity]->(ca)-[:physicalEntity]->(c) " + f"MATCH (c){_HOLDS}(g:ReferenceGeneProduct) WHERE g.geneName[0] IN ['CDK4', 'CDK6'] " + "OPTIONAL MATCH (ca)-[:activeUnit]->(u) " + "OPTIONAL MATCH (u)" + _HOLDS + "(gu:ReferenceGeneProduct) WHERE gu.geneName[0] IN ['CDK4', 'CDK6'] " + "WITH r, u, gu WHERE u IS NULL OR gu IS NOT NULL " + "RETURN DISTINCT r.stId AS target"), +] + + +def summation_regulations(graph, reaction_ids: Sequence[str]) -> Dict[str, Dict[str, List[str]]]: + """target reaction stId -> {"pos": [regulator stIds], "neg": [...]} for the + pathway whose reactions are `reaction_ids`.""" + STATS.clear() + rids = sorted(set(str(r) for r in reaction_ids)) + held = set(rids) + out: Dict[str, Dict[str, List[str]]] = {} + for rule, source, regulator, sign, cypher in RULES: + if source not in held: + continue + for row in graph.run(cypher, rids=rids).data(): + t = row["target"] + if t == source: + continue + lst = out.setdefault(t, {"pos": [], "neg": []})[sign] + if regulator not in lst: + lst.append(regulator) + STATS[rule] += 1 + for d in out.values(): + d["pos"].sort(); d["neg"].sort() + return out diff --git a/src/variant_emission.py b/src/variant_emission.py index 6abd1ed..4cbec9a 100644 --- a/src/variant_emission.py +++ b/src/variant_emission.py @@ -355,6 +355,14 @@ def build_variant_reactions(graph, reaction_ids: Sequence[str] STATS.clear() cap = vk._max_variants() parts_by_rx = fetch_participants(graph, reaction_ids) + # specs/102: a summation regulator is read as ONE pool of its variants, added + # after the copies are chosen, so it never multiplies them or pushes a + # reaction past the cap (as a curated participant it took the RBL1/RBL2 + # phosphorylations over the cap and broke their recycle pools). + supplement: Dict[str, Dict[str, List[str]]] = {} + if env_flag("LNG_SUMMATION_REGULATIONS"): + from src.summation_regulations import summation_regulations + supplement = summation_regulations(graph, reaction_ids) rows: List[dict] = [] vr_entities: Dict[str, tuple] = {} cat_rows: List[dict] = []; neg_rows: List[dict] = []; pos_rows: List[dict] = [] @@ -398,6 +406,12 @@ def name(e, s=sig, pooled=pooled, output=False): "edge_type": "catalyst" if target is cat_rows else "regulator", "uuid": str(uuid.uuid4()), "reaction_uuid": uid, "participant": e}) + for sign, target in (("neg", neg_rows), ("pos", pos_rows)): + for e in supplement.get(rx, {}).get(sign, []): + target.append({"reaction_id": rx, "entity_id": e + POOL_SUFFIX, + "edge_type": "regulator", "uuid": str(uuid.uuid4()), + "reaction_uuid": uid, "participant": e}) + STATS["summation_regulator_rows"] += 1 rid_map = pd.DataFrame(rows, columns=["uid", "reactome_id", "input_hash", "output_hash"]) mk = lambda r: pd.DataFrame(r, columns=_CAT_REG_COLUMNS) # noqa: E731 return rid_map, vr_entities, mk(cat_rows), mk(neg_rows), mk(pos_rows) diff --git a/tests/test_summation_regulations.py b/tests/test_summation_regulations.py new file mode 100644 index 0000000..baed492 --- /dev/null +++ b/tests/test_summation_regulations.py @@ -0,0 +1,74 @@ +"""deltasignal specs/102: regulations a reaction's summation states +(LNG_SUMMATION_REGULATIONS).""" +import pytest + +import src.summation_regulations as sr + + +class _FakeGraph: + def __init__(self, targets): + self.targets = targets + + def run(self, cypher, rids): + rule = next(r for r in sr.RULES if r[4] == cypher)[0] + rows = [{"target": t} for t in self.targets.get(rule, []) if t in rids] + + class _R: + def data(self): + return rows + return _R() + + +def test_a_rule_applies_only_where_its_source_reaction_is(): + g = _FakeGraph({"E1": ["R-HSA-TK1"], "E2": ["R-HSA-BIND"]}) + # Mitotic G1 holds RB1 binding E2F (E1's source) but not AKT's p27 phosphorylation (E2's) + regs = sr.summation_regulations(g, ["R-HSA-9018017", "R-HSA-TK1", "R-HSA-BIND"]) + assert regs == {"R-HSA-TK1": {"pos": [], "neg": ["R-HSA-68644"]}} + assert sr.STATS == {"E1": 1} + + +def test_signs_and_the_source_is_never_its_own_target(): + g = _FakeGraph({"E2": ["R-HSA-198613", "R-HSA-BIND"], "E3": ["R-HSA-69227"]}) + regs = sr.summation_regulations(g, ["R-HSA-198613", "R-HSA-8942607", "R-HSA-BIND", "R-HSA-69227"]) + assert regs == {"R-HSA-BIND": {"pos": [], "neg": ["R-HSA-198605"]}, + "R-HSA-69227": {"pos": ["R-HSA-8942622"], "neg": []}} + + +def test_flag_on_by_default(monkeypatch): + from src.env_flags import env_flag + monkeypatch.delenv("LNG_SUMMATION_REGULATIONS", raising=False) + assert env_flag("LNG_SUMMATION_REGULATIONS") is True + + +@pytest.mark.database +def test_release_targets_in_mitotic_g1(): + """The registered targets (specs/102 spec.md), read from the database.""" + from src.neo4j_connector import get_graph + graph = get_graph() + rids = [r["s"] for r in graph.run( + "MATCH (:Pathway {stId: 'R-HSA-453279'})-[:hasEvent*]->(r:ReactionLikeEvent) " + "RETURN DISTINCT r.stId AS s").data()] + regs = sr.summation_regulations(graph, rids) + e1 = [t for t, d in regs.items() if "R-HSA-68644" in d["neg"]] + assert len(e1) == 15 + assert sorted(t for t, d in regs.items() if "R-HSA-198605" in d["neg"]) == ["R-HSA-69562", "R-HSA-8941915"] + assert sorted(t for t, d in regs.items() if "R-HSA-8942622" in d["pos"]) == [ + "R-HSA-1226094", "R-HSA-1226095", "R-HSA-69227"] + + +def test_a_summation_regulator_is_one_pool_and_never_multiplies_copies(monkeypatch): + """specs/102: as a curated participant the regulator's set choices multiplied the + copies and took the RBL1/RBL2 phosphorylations past the cap.""" + import src.variant_emission as ve + from tests.test_variant_emission import _mdm_world + p = _mdm_world(monkeypatch) + monkeypatch.setattr(ve, "fetch_participants", lambda g, ids: {"RX": p}) + monkeypatch.setattr(sr, "summation_regulations", lambda g, ids: {"RX": {"pos": [], "neg": ["REG"]}}) + monkeypatch.setenv("LNG_BALANCE_CHOICES", "0") + monkeypatch.setenv("LNG_SUMMATION_REGULATIONS", "0") + rid0, *_ = ve.build_variant_reactions(None, ["RX"]) + monkeypatch.setenv("LNG_SUMMATION_REGULATIONS", "1") + rid1, _, _, neg, pos = ve.build_variant_reactions(None, ["RX"]) + assert len(rid1) == len(rid0) == 4 + assert sorted(neg["entity_id"]) == ["REG::pool"] * 4 and len(pos) == 0 + assert set(neg["reaction_uuid"]) == set(rid1["uid"])