diff --git a/src/env_flags.py b/src/env_flags.py index 0239a85..dcaa72d 100644 --- a/src/env_flags.py +++ b/src/env_flags.py @@ -14,6 +14,7 @@ BOOL_FLAGS = { "LNG_BOUNDARY_EXPANSION": True, "LNG_BOUNDARY_HIERARCHY": True, + "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, "LNG_COMPLEX_AS_NODE": True, @@ -24,6 +25,7 @@ "LNG_DIAGRAM_SET_MEMBER": False, "LNG_EMIT_ONE_SIDED": True, "LNG_HANDOFF_EDGES": False, + "LNG_PATHOGEN_PROTEIN": True, # default since deltasignal specs/048 amendment 2 (arm pathp: exp +7, held-out +1, 0 broken) "LNG_SET_EXPAND": True, "LNG_SET_MEMBERS_OR": False, "LNG_SET_POOL": True, diff --git a/src/logic_network_generator.py b/src/logic_network_generator.py index 9790f0f..48a21f6 100755 --- a/src/logic_network_generator.py +++ b/src/logic_network_generator.py @@ -3901,7 +3901,34 @@ def export_drugs(pathway_logic_network: pd.DataFrame, Output CSV columns: stable_id, schema_class, name, reactome_release. """ - from src.neo4j_connector import get_drug_entities, get_reactome_release + from src.neo4j_connector import get_drug_entities + _export_derived(pathway_logic_network, reactome_id_to_uuid, output_file, + get_drug_entities, "drug-derived") + + +def export_pathogens(pathway_logic_network: pd.DataFrame, + reactome_id_to_uuid: Dict[str, str], + output_file: str) -> None: + """Write pathogens.csv: which entities in THIS network are pathogen-derived + (deltasignal specs/048), by the rule of + :func:`src.neo4j_connector.get_pathogen_entities`. Reactome curates + host-pathogen interactions inside human pathways (the SARS-CoV-2 + N:M:PDPK1 complex inhibits AKT T308 phosphorylation in PIP3 signalling); a + consumer modelling an uninfected cell holds them at baseline, as it holds + drugs. Same columns, variant resolution and always-written rule as + drugs.csv. + """ + from src.neo4j_connector import get_pathogen_entities + _export_derived(pathway_logic_network, reactome_id_to_uuid, output_file, + get_pathogen_entities, "pathogen-derived") + + +def _export_derived(pathway_logic_network: pd.DataFrame, + reactome_id_to_uuid: Dict[str, str], + output_file: str, finder, label: str) -> None: + """The node listing shared by drugs.csv and pathogens.csv. ``finder`` maps + stable ids to {stId: {"schema_class", "name"}} for the derived ones.""" + from src.neo4j_connector import get_reactome_release node_ids: set[str] = set() if not pathway_logic_network.empty: @@ -3923,20 +3950,20 @@ def parts(node_id: str): parent, members = parts(n) wanted.add(parent) wanted.update(members) - drugs = get_drug_entities(wanted) + derived = finder(wanted) release = get_reactome_release() rows = [] for n in sorted(node_ids): parent, members = parts(n) - hit = parent if parent in drugs else next((m for m in members if m in drugs), None) + hit = parent if parent in derived else next((m for m in members if m in derived), None) if hit is None: continue - rows.append({"stable_id": n, "schema_class": drugs[hit]["schema_class"], - "name": drugs[hit]["name"], + rows.append({"stable_id": n, "schema_class": derived[hit]["schema_class"], + "name": derived[hit]["name"], "reactome_release": release if release is not None else ""}) pd.DataFrame(rows, columns=["stable_id", "schema_class", "name", "reactome_release"]).to_csv( output_file, index=False) - logger.info(f"Exported {len(rows)} drug-derived entities to {output_file}") + logger.info(f"Exported {len(rows)} {label} entities to {output_file}") POOL_MAX_STEPS = 6 # a state -> intermediates -> state path longer than this is dropped diff --git a/src/neo4j_connector.py b/src/neo4j_connector.py index 7f40ac2..6db08dd 100755 --- a/src/neo4j_connector.py +++ b/src/neo4j_connector.py @@ -933,6 +933,97 @@ def derived(s: str, depth: int = 0) -> bool: for s in ids if derived(s)} +HUMAN_TAXON = "9606" +# stId -> (species taxIds, [(relationship type, child stId)], schema class, display name) +_pathogen_structure_cache: Dict[str, Tuple[List[str], List[Tuple[str, str]], str, str]] = {} +# stId -> schema class of its referenceEntity ("" when it has none), for LNG_PATHOGEN_PROTEIN +_pathogen_ref_cache: Dict[str, str] = {} +# A protein leaf: an EWAS on a ReferenceGeneProduct (ReferenceIsoform is its subclass). +PROTEIN_REFERENCE_CLASSES = frozenset({"ReferenceGeneProduct", "ReferenceIsoform"}) + + +def get_pathogen_entities(stable_ids) -> Dict[str, Dict[str, str]]: + """The entities among ``stable_ids`` that are PATHOGEN-DERIVED, with their + schema class and display name (deltasignal specs/048). + + Reactome curates host-pathogen interactions inside human pathways: the + SARS-CoV-2 N:M:PDPK1 complex negatively regulates "PDPK1 phosphorylates + AKT at T308". A benchmark describes a cell without the pathogen. Decided + structurally, as drugs are (:func:`get_drug_entities`): + - a leaf (no components, members or candidates) is, when it has a species + and none is Homo sapiens; + - a complex is, when ANY component is; + - a set is, only when EVERY member is. + A leaf with no species (most small molecules) is not. + + Under ``LNG_PATHOGEN_PROTEIN=1`` (specs/048 amendment 2) a leaf counts only + when it is also a PROTEIN: an EWAS on a ReferenceGeneProduct or + ReferenceIsoform. Viral nucleic acids are then not pathogen-derived, whether + curated as a GenomeEncodedEntity with no reference entity (the DDX58/IFIH1 + ligands in our catalog) or as an EWAS on a ReferenceRNASequence / + ReferenceDNASequence: in DDX58/IFIH1 the RNA is the ligand the pathway + senses, and every signalling complex there carries one. + + A Polymer's ``repeatedUnit`` is read as a component, so a viral protein + multimer (HIV-1 Rev-multimer) counts through its unit; a Polymer has no + reference entity of its own. + """ + protein_only = env_flag("LNG_PATHOGEN_PROTEIN") + ids = sorted({s for s in stable_ids if s}) + missing = [s for s in ids if s not in _pathogen_structure_cache] + if missing: + query = """ + UNWIND $ids AS s + MATCH (n:PhysicalEntity {stId: s})-[:hasComponent|hasMember|hasCandidate|repeatedUnit*0..10]->(x) + WITH DISTINCT x + OPTIONAL MATCH (x)-[:species]->(sp) + OPTIONAL MATCH (x)-[:referenceEntity]->(re) + OPTIONAL MATCH (x)-[r:hasComponent|hasMember|hasCandidate|repeatedUnit]->(y) + RETURN x.stId AS x, x.schemaClass AS c, x.displayName AS d, + re.schemaClass AS ref, + collect(DISTINCT toString(sp.taxId)) AS tax, + collect(DISTINCT CASE WHEN y IS NULL THEN NULL ELSE [type(r), y.stId] END) AS kids + """ + try: + rows = get_graph().run(query, ids=missing).data() + except Exception: + logger.error("Error in get_pathogen_entities", **_traceback_kwargs()) + raise + for r in rows: + if r.get("x"): + _pathogen_structure_cache[r["x"]] = ( + [t for t in r["tax"] if t], [tuple(k) for k in r["kids"] if k and k[1]], + r.get("c") or "", r.get("d") or "") + _pathogen_ref_cache[r["x"]] = r.get("ref") or "" + for s in missing: + _pathogen_structure_cache.setdefault(s, ([], [], "", "")) + + memo: Dict[str, bool] = {} + + def derived(s: str, depth: int = 0) -> bool: + if s in memo: + return memo[s] + memo[s] = False # a malformed self-containing entity cannot recurse + tax, kids, _, _ = _pathogen_structure_cache.get(s, ([], [], "", "")) + comps = [y for t, y in kids if t in ("hasComponent", "repeatedUnit")] + members = [y for t, y in kids if t in ("hasMember", "hasCandidate")] + if depth >= 10: + out = False + elif comps: + out = any(derived(y, depth + 1) for y in comps) + elif members: + out = all(derived(y, depth + 1) for y in members) + else: + out = bool(tax) and HUMAN_TAXON not in tax and ( + not protein_only or _pathogen_ref_cache.get(s, "") in PROTEIN_REFERENCE_CLASSES) + memo[s] = out + return out + + return {s: {"schema_class": _pathogen_structure_cache[s][2], + "name": _pathogen_structure_cache[s][3]} + for s in ids if derived(s)} + + # Group donors: a reaction consuming one ADDS a group to its substrate, so its # product is the modified form of an interconversion pool (specs/039). ChEBI # ids: ATP, GTP, S-adenosyl-L-methionine, acetyl-CoA, NAD+. diff --git a/src/pathway_generator.py b/src/pathway_generator.py index fea0c84..d5260f7 100755 --- a/src/pathway_generator.py +++ b/src/pathway_generator.py @@ -13,6 +13,7 @@ create_pathway_logic_network, export_cofactors, export_drugs, + export_pathogens, export_pools, export_containment, export_containment_structure, @@ -42,6 +43,8 @@ "LNG_SET_POOL", "LNG_CAP_POOLS", "LNG_POOL_ACTIVE_VIA", + "LNG_BIND_STOICH", + "LNG_PATHOGEN_PROTEIN", "LNG_VARIANT_NODES", "LNG_DIAGRAM_SET_MEMBER", # Of these three, only LNG_EMIT_ONE_SIDED is a genuine cache gap: it is @@ -219,7 +222,7 @@ def _write_cache_fingerprint( "logic_network.csv", "stid_to_uuid_mapping.csv", "entity_reaction_proxy_mapping.csv", "nodes.csv", "node_reaction_context.csv", "node_resolution.csv", "node_exclusions.csv", "boundary_edges.csv", "cofactors.csv", "containment.csv", "containment_structure.csv", - "drugs.csv", + "drugs.csv", "pathogens.csv", "pools.csv", "pool_transitions.csv", "pool_carriers.csv", ) @@ -566,6 +569,17 @@ def generate_pathway_file( logger.error(f"Failed to write drugs.csv: {e}", exc_info=True) raise + # Pathogen-derived entities (deltasignal specs/048), likewise on their own. + try: + export_pathogens( + result.logic_network, + result.uuid_mapping, + str(pathway_output_dir / "pathogens.csv"), + ) + except Exception as e: + logger.error(f"Failed to write pathogens.csv: {e}", exc_info=True) + raise + # Interconversion pools (deltasignal specs/039), in their own block. try: export_pools( diff --git a/src/variant_emission.py b/src/variant_emission.py index 9e2081e..5af806d 100644 --- a/src/variant_emission.py +++ b/src/variant_emission.py @@ -38,6 +38,7 @@ import pandas as pd from src import variant_keys as vk +from src.env_flags import env_flag # `participant` is the curated catalyst/regulator the row came from (a bare # set's copies name its MEMBERS in entity_id), read by depleter pooling. @@ -93,22 +94,44 @@ def _slots_of(participants: Sequence[str]) -> Set[str]: return out -_sig_cache: Dict[str, Tuple[str, ...]] = {} +_sig_cache: Dict[Tuple[str, bool], Tuple[str, ...]] = {} # keyed by (member, LNG_BIND_STOICH) def _reference_signature(member: str) -> Tuple[str, ...]: """Isoform-level identity of a set member: the sorted multiset of its leaves' reference entities (a member complex keeps its leaf multiset).""" - if member in _sig_cache: - return _sig_cache[member] + stoich = env_flag("LNG_BIND_STOICH") + if (member, stoich) in _sig_cache: + return _sig_cache[(member, stoich)] from src.neo4j_connector import get_reference_entity_id from src.reaction_generator import get_terminal_components - leaves = sorted(get_terminal_components(member)) if vk.is_complex(member) else [member] + if vk.is_complex(member) and stoich: + leaves = _leaf_multiset(member) + else: + leaves = sorted(get_terminal_components(member)) if vk.is_complex(member) else [member] sig = tuple(sorted(str(get_reference_entity_id(x) or x) for x in leaves)) - _sig_cache[member] = sig + _sig_cache[(member, stoich)] = sig return sig +def _leaf_multiset(entity: str, depth: int = 0) -> List[str]: + """The leaves of a complex with their stoichiometry, as a multiset + (deltasignal specs/048 B3). ``get_terminal_components`` returns a set, so a + homodimer (MAPK1 dimer, R-HSA-5675354) had the signature of its monomer + (MAPK1, R-HSA-59282), and "Cytosolic DUSPs dephosphorylate MAPKs" + (R-HSA-5675376) bound p-MAPK1 to the MAPK1 DIMER and the p-MAPK1 dimer to + the monomer, by sorted rank. A set inside a complex contributes its + leaves once each, as before.""" + from src.neo4j_connector import get_complex_components + from src.reaction_generator import get_terminal_components + if depth > 10 or not vk.is_complex(entity): + return sorted(get_terminal_components(entity)) or [entity] + out: List[str] = [] + for comp, st in sorted(get_complex_components(entity).items()): + out.extend(_leaf_multiset(comp, depth + 1) * int(st or 1)) + return sorted(out) or [entity] + + def bind_output_slots(input_side: Set[str], output_slots: Set[str]) -> Dict[str, Tuple[str, Dict[str, str]]]: """Output-only slot -> (input-side slot, {input member: output member}). diff --git a/tests/test_export_failfast.py b/tests/test_export_failfast.py index 4121e30..e345588 100644 --- a/tests/test_export_failfast.py +++ b/tests/test_export_failfast.py @@ -24,7 +24,7 @@ EXPORTS = ["export_uuid_to_reactome_mapping", "export_entity_reaction_proxy_mapping", "export_nodes", "export_node_reaction_context", "export_node_resolution", "export_cofactors", "export_containment", "export_containment_structure", - "export_drugs", "export_pools"] + "export_drugs", "export_pathogens", "export_pools"] @pytest.fixture diff --git a/tests/test_provenance_exports.py b/tests/test_provenance_exports.py index 82cc15d..15bdc41 100644 --- a/tests/test_provenance_exports.py +++ b/tests/test_provenance_exports.py @@ -376,3 +376,137 @@ def data(_): assert calls == [["C", "R-RXN"]] assert neo4j_connector.get_drug_entities({"C", "R-RXN"}) == got assert len(calls) == 1 # second call served from the cache + + +# --- pathogens.csv (deltasignal specs/048) ------------------------------------ + +def test_export_pathogens_shares_the_drug_listing(tmp_path, monkeypatch): + # Same variant resolution as drugs.csv: a variant that CHOSE a viral member + # is listed under its exact node id. + monkeypatch.setattr(neo4j_connector, "get_pathogen_entities", + lambda stids: {"R-COV-M": {"schema_class": "EWAS", "name": "M [plasma membrane]"}}) + monkeypatch.setattr(neo4j_connector, "get_reactome_release", lambda: 97) + vm, vp = "R-HSA-S::variant::R-COV-M", "R-HSA-S::variant::R-HSA-P" + out = tmp_path / "pathogens.csv" + m.export_pathogens(pd.DataFrame([{"source_id": "u-1", "target_id": "u-2"}]), {vm: "u-1", vp: "u-2"}, str(out)) + df = pd.read_csv(out) + assert list(df.columns) == ["stable_id", "schema_class", "name", "reactome_release"] + assert list(df.stable_id) == [vm] + + +def test_pathogen_rule_species_leaf_complex_any_set_every(monkeypatch): + # V is viral (taxon 2697049); H is human; X has two species, one human (not + # pathogen-derived); A has no species (a small molecule: not). N:M:PDPK1 = + # complex(V, H) is; S_all = set{V, V2} is; S_mixed = set{V, H} is not; + # C2 = complex(H, S_mixed) is not. + cache = { + "V": (["2697049"], [], "EWAS", "M"), "V2": (["2697049"], [], "EWAS", "N"), + "H": (["9606"], [], "EWAS", "PDPK1"), "X": (["9606", "11320"], [], "EWAS", "x"), + "A": ([], [], "SimpleEntity", "ATP"), + "NMP": (["9606", "2697049"], [("hasComponent", "V"), ("hasComponent", "H")], "Complex", "N:M:PDPK1"), + "S_all": ([], [("hasCandidate", "V"), ("hasCandidate", "V2")], "CandidateSet", "s"), + "S_mixed": ([], [("hasMember", "V"), ("hasMember", "H")], "DefinedSet", "s"), + "C2": ([], [("hasComponent", "H"), ("hasComponent", "S_mixed")], "Complex", "c"), + } + monkeypatch.setattr(neo4j_connector, "_pathogen_structure_cache", dict(cache)) + monkeypatch.setenv("LNG_PATHOGEN_PROTEIN", "0") # the species rule alone; protein-only is tested below + + def boom(): + raise AssertionError("fully cached: no query expected") + monkeypatch.setattr(neo4j_connector, "get_graph", boom) + got = neo4j_connector.get_pathogen_entities(cache) + assert set(got) == {"V", "V2", "NMP", "S_all"} + assert got["NMP"] == {"schema_class": "Complex", "name": "N:M:PDPK1"} + + +def test_get_pathogen_entities_queries_uncached_ids_and_keeps_misses(monkeypatch): + monkeypatch.setattr(neo4j_connector, "_pathogen_structure_cache", {}) + monkeypatch.setattr(neo4j_connector, "_pathogen_ref_cache", {}) + calls = [] + + class G: + def run(self, q, **kw): + calls.append(sorted(kw["ids"])) + + class R: + def data(_): + return [{"x": "C", "c": "Complex", "d": "N:M:PDPK1", "tax": ["9606", "2697049"], + "kids": [["hasComponent", "V"], ["hasComponent", "H"]]}, + {"x": "V", "c": "EWAS", "d": "M", "tax": ["2697049"], "kids": [None], + "ref": "ReferenceGeneProduct"}, + {"x": "H", "c": "EWAS", "d": "PDPK1", "tax": ["9606"], "kids": []}] + return R() + monkeypatch.setattr(neo4j_connector, "get_graph", lambda: G()) + got = neo4j_connector.get_pathogen_entities({"C", "R-RXN"}) + assert got == {"C": {"schema_class": "Complex", "name": "N:M:PDPK1"}} + assert neo4j_connector.get_pathogen_entities({"C", "R-RXN"}) == got + assert calls == [["C", "R-RXN"]] + + +def test_pathogen_protein_only_drops_viral_rna_and_its_complexes(monkeypatch): + # specs/048 amendment 2, with the real classes: R = viral dsRNA + # (GenomeEncodedEntity, no reference entity), P = viral protein (EWAS on a + # ReferenceGeneProduct), I = viral isoform, H = host. LIG = set{R} is the + # "DDX58 ligand"; SIG = complex(H, LIG) is a RIG-I signalling complex; + # NMP = complex(P, H) is N:M:PDPK1; MIX = complex(R, P, H). + cache = { + "R": (["11320"], [], "GenomeEncodedEntity", "dsRNA"), + "P": (["2697049"], [], "EntityWithAccessionedSequence", "M"), + "I": (["2697049"], [], "EntityWithAccessionedSequence", "nsp13"), + "H": (["9606"], [], "EntityWithAccessionedSequence", "DDX58"), + "LIG": ([], [("hasMember", "R")], "DefinedSet", "DDX58 ligand"), + "SIG": ([], [("hasComponent", "H"), ("hasComponent", "LIG")], "Complex", "dsRNA:DDX58"), + "NMP": ([], [("hasComponent", "P"), ("hasComponent", "H")], "Complex", "N:M:PDPK1"), + "MIX": ([], [("hasComponent", "R"), ("hasComponent", "P"), ("hasComponent", "H")], "Complex", "mix"), + } + refs = {"R": "", "P": "ReferenceGeneProduct", "I": "ReferenceIsoform", "H": "ReferenceGeneProduct"} + monkeypatch.setattr(neo4j_connector, "_pathogen_structure_cache", dict(cache)) + monkeypatch.setattr(neo4j_connector, "_pathogen_ref_cache", dict(refs)) + monkeypatch.setattr(neo4j_connector, "get_graph", lambda: (_ for _ in ()).throw(AssertionError("cached"))) + monkeypatch.setenv("LNG_PATHOGEN_PROTEIN", "0") + assert set(neo4j_connector.get_pathogen_entities(cache)) == {"R", "P", "I", "LIG", "SIG", "NMP", "MIX"} + monkeypatch.setenv("LNG_PATHOGEN_PROTEIN", "1") + assert set(neo4j_connector.get_pathogen_entities(cache)) == {"P", "I", "NMP", "MIX"} + + +def test_pathogen_query_records_reference_class(monkeypatch): + monkeypatch.setattr(neo4j_connector, "_pathogen_structure_cache", {}) + monkeypatch.setattr(neo4j_connector, "_pathogen_ref_cache", {}) + + class G: + def run(self, q, **kw): + assert "referenceEntity" in q + + class R: + def data(_): + return [{"x": "C", "c": "Complex", "d": "dsRNA:DDX58", "ref": None, "tax": [], + "kids": [["hasComponent", "R"], ["hasComponent", "H"]]}, + {"x": "R", "c": "GenomeEncodedEntity", "d": "dsRNA", "ref": None, + "tax": ["11320"], "kids": []}, + {"x": "H", "c": "EWAS", "d": "DDX58", "ref": "ReferenceGeneProduct", + "tax": ["9606"], "kids": []}] + return R() + monkeypatch.setattr(neo4j_connector, "get_graph", lambda: G()) + monkeypatch.setenv("LNG_PATHOGEN_PROTEIN", "1") + assert neo4j_connector.get_pathogen_entities({"C"}) == {} + assert neo4j_connector._pathogen_ref_cache == {"C": "", "R": "", "H": "ReferenceGeneProduct"} + + +def test_pathogen_polymer_counts_through_its_repeated_unit(monkeypatch): + # HIV-1 Rev-multimer: a Polymer (no reference entity) of a viral protein. + # RNA = viral RNA as an EWAS on a ReferenceRNASequence: not a protein. + cache = { + "REV": (["11676"], [], "EntityWithAccessionedSequence", "Rev"), + "POLY": (["11676"], [("repeatedUnit", "REV")], "Polymer", "Rev-multimer"), + "RNA": (["11676"], [], "EntityWithAccessionedSequence", "HIV-1 RNA"), + "H": (["9606"], [], "EntityWithAccessionedSequence", "XPO1"), + "C": ([], [("hasComponent", "POLY"), ("hasComponent", "H")], "Complex", "Rev-multimer:XPO1"), + "R": ([], [("hasComponent", "RNA"), ("hasComponent", "H")], "Complex", "RNA:XPO1"), + } + refs = {"REV": "ReferenceGeneProduct", "POLY": "", "RNA": "ReferenceRNASequence", "H": "ReferenceGeneProduct"} + monkeypatch.setattr(neo4j_connector, "_pathogen_structure_cache", dict(cache)) + monkeypatch.setattr(neo4j_connector, "_pathogen_ref_cache", dict(refs)) + monkeypatch.setattr(neo4j_connector, "get_graph", lambda: (_ for _ in ()).throw(AssertionError("cached"))) + monkeypatch.setenv("LNG_PATHOGEN_PROTEIN", "1") + assert set(neo4j_connector.get_pathogen_entities(cache)) == {"REV", "POLY", "C"} + diff --git a/tests/test_variant_emission.py b/tests/test_variant_emission.py index d300766..e3b21d2 100644 --- a/tests/test_variant_emission.py +++ b/tests/test_variant_emission.py @@ -4,6 +4,8 @@ import src.variant_emission as ve import src.variant_keys as vk +REAL_SIGNATURE = ve._reference_signature # the autouse stub replaces it per test + LABELS = {"CX": ["Complex"], "SET": ["EntitySet"], "OSET": ["EntitySet"], "GDP": ["SimpleEntity"], "DIMER": ["Complex"], "CAT": ["Complex"], "TIE": ["EntitySet"], "OTIE": ["EntitySet"]} COMP = {"CX": ["GDP", "SET"], "DIMER": ["SET", "SET"], "CAT": ["SET", "GDP"]} @@ -153,3 +155,71 @@ def test_step1_does_not_pool_a_participant_sharing_a_slot_with_a_kept_one(cap_wo for d, ks in ((CAP_LABELS, "ABST"), (CAP_COMP, "AB"), (CAP_MEM, "ST")): for k in ks: d.pop(k, None) + + +# R-HSA-5675376 with its real ids, whose sorted order is what crossed the pairing. +PM, PD, M, D = "R-HSA-109853", "R-HSA-109855", "R-HSA-59282", "R-HSA-5675354" + + +def _dusp_lookups(monkeypatch): + """p-T,Y MAPK monomers and dimers -> MAPK monomers and dimers. PM = p-MAPK1, + PD = p-MAPK1 dimer (PM x2); M = MAPK1, D = MAPK1 dimer (M x2).""" + import src.neo4j_connector as nc + import src.reaction_generator as rg + labels = {"IN": ["EntitySet"], "OUT": ["EntitySet"], PD: ["Complex"], D: ["Complex"]} + vk._lookups.update(labels=lambda s: labels.get(s, ["EWAS"]), components=lambda s: [], + members=lambda s: {"IN": [PD, PM], "OUT": [D, M]}.get(s, [])) + vk.reset_caches() + monkeypatch.setattr(ve, "_reference_signature", REAL_SIGNATURE) + monkeypatch.setattr(nc, "get_reference_entity_id", lambda x: {PM: "MAPK1", M: "MAPK1"}.get(x)) + monkeypatch.setattr(nc, "get_complex_components", lambda x: {PD: {PM: 2}, D: {M: 2}}.get(x, {})) + monkeypatch.setattr(rg, "get_terminal_components", lambda x: {PD: {PM}, D: {M}}.get(x, {x})) + + +def test_homodimer_binds_to_homodimer_not_monomer(monkeypatch): + """specs/048: the signature keeps a complex's leaf multiset. Without it the + dimer reads as its monomer and the tie is broken by sorted rank, which + paired p-MAPK1 with the MAPK1 DIMER.""" + _dusp_lookups(monkeypatch) + monkeypatch.setenv("LNG_BIND_STOICH", "1") + assert ve._reference_signature(PD) == ("MAPK1", "MAPK1") + assert ve._reference_signature(PM) == ("MAPK1",) + (slot, mapping), = ve.bind_output_slots({"IN"}, {"OUT"}).values() + assert slot == "IN" and mapping == {PM: M, PD: D} + assert ve.STATS.get("slot_binding_ties", 0) == 0 + + +def test_homodimer_binding_off_reproduces_the_crossed_pairing(monkeypatch): + _dusp_lookups(monkeypatch) + monkeypatch.setenv("LNG_BIND_STOICH", "0") + (slot, mapping), = ve.bind_output_slots({"IN"}, {"OUT"}).values() + assert mapping == {PM: D, PD: M} + assert ve.STATS["slot_binding_ties"] == 1 + + +def test_leaf_multiset_multiplies_stoichiometry_through_nesting(monkeypatch): + """A 2x(homodimer) complex reads as four leaves, not two: stoichiometry + multiplies at every level, and a set inside a complex contributes its + alternatives once each, times its slot.""" + import src.neo4j_connector as nc + import src.reaction_generator as rg + labels = {"TETRA": ["Complex"], "DIM": ["Complex"], "MIX": ["Complex"], "S": ["EntitySet"]} + vk._lookups.update(labels=lambda s: labels.get(s, ["EWAS"])) + vk.reset_caches() + comps = {"TETRA": {"DIM": 2}, "DIM": {"M": 2}, "MIX": {"S": 3, "M": 1}} + monkeypatch.setattr(nc, "get_complex_components", lambda x: comps.get(x, {})) + monkeypatch.setattr(rg, "get_terminal_components", lambda x: {"S": {"A", "B"}}.get(x, {x})) + assert ve._leaf_multiset("TETRA") == ["M"] * 4 + assert ve._leaf_multiset("DIM") == ["M", "M"] + assert ve._leaf_multiset("MIX") == sorted(["A", "B"] * 3 + ["M"]) + + +def test_signature_cache_is_keyed_on_the_flag(monkeypatch): + """A signature cached under one LNG_BIND_STOICH value is not reused under + the other (the review of PR #110: the cache was keyed on the member only).""" + _dusp_lookups(monkeypatch) + monkeypatch.setenv("LNG_BIND_STOICH", "0") + assert ve._reference_signature(PD) == ("MAPK1",) + monkeypatch.setenv("LNG_BIND_STOICH", "1") + assert ve._reference_signature(PD) == ("MAPK1", "MAPK1") +