From 5bc4d88d03193701ff47a0c03dcd5b9040fd6558 Mon Sep 17 00:00:00 2001 From: Adam Wright <668955+adamjohnwright@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:57:27 -0400 Subject: [PATCH 1/7] WIP specs/048: pathogens.csv, stoichiometric binding signature, pool joins through member/split hops The pool joins (LNG_POOL_JOIN_MEMBERS, LNG_POOL_REGEN=protein) find the MAPK1 and MAPK3 pools in RAF, which then share 246 capped intermediate nodes and are both dropped. Kept here for the shared-intermediate design. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/env_flags.py | 3 ++ src/logic_network_generator.py | 96 ++++++++++++++++++++++++++++++---- src/neo4j_connector.py | 70 +++++++++++++++++++++++++ src/pathway_generator.py | 17 +++++- src/variant_emission.py | 24 ++++++++- 5 files changed, 199 insertions(+), 11 deletions(-) diff --git a/src/env_flags.py b/src/env_flags.py index 0239a85..690ae57 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": False, # deltasignal specs/048 lever B3 "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_POOL_JOIN_MEMBERS": False, # deltasignal specs/048 lever B1 "LNG_SET_EXPAND": True, "LNG_SET_MEMBERS_OR": False, "LNG_SET_POOL": True, @@ -37,6 +39,7 @@ "LNG_HANDOFF_HUB_MAX", # _int_env "LNG_MAX_VARIANTS", # _int_env "LNG_POOL_ACTIVE_VIA", # pool_active_via() + "LNG_POOL_REGEN", # pool_regen() "LNG_PYTHON", # catalog.sh: the interpreter, not a switch }) diff --git a/src/logic_network_generator.py b/src/logic_network_generator.py index 9790f0f..a014def 100755 --- a/src/logic_network_generator.py +++ b/src/logic_network_generator.py @@ -512,6 +512,7 @@ def _reject_removed_env() -> None: raise ValueError(f"{name} was removed: {why}") validate_env(removed=_REMOVED_ENV) pool_active_via() + pool_regen() _POOL_ACTIVE_VIA = ("direct", "made_from") @@ -533,6 +534,24 @@ def pool_active_via() -> str: return value +_POOL_REGEN = ("entity", "protein") + + +def pool_regen() -> str: + """LNG_POOL_REGEN (deltasignal specs/048 lever B2): how a multi-step pool + path proves it regenerates what it consumes (specs/039 amendment 5). + ``entity`` (default): every consumed non-R, non-small input is output again + by a step of the path, compared by stable id. ``protein``: every protein a + consumed input carries is output again, in any form, so a partner released + under another stable id (RAS:GTP:activated RAF dimers, R-HSA-5672718 in and + R-HSA-5672712 out) or modified (MAP2K in, p-2S MAP2K out) still counts. + Any other value is an error at startup.""" + value = os.environ.get("LNG_POOL_REGEN", "entity") + if value not in _POOL_REGEN: + raise ValueError(f"LNG_POOL_REGEN must be one of {_POOL_REGEN}, got {value!r}") + return value + + def _register_phase1( vr_entities: Dict[str, tuple], entity_uuid_registry: Dict[tuple, str], @@ -3901,7 +3920,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 +3969,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 @@ -4038,6 +4084,10 @@ def find_pools(pathway_logic_network: pd.DataFrame, reaction_id_map: pd.DataFram acts: Dict[str, Set[str]] = {} # node -> reaction nodes it catalyses or positively regulates set_pools_of: Dict[str, Set[str]] = {} # member node -> the set-pool nodes it feeds (specs/033) feeds: Dict[str, Set[str]] = {} # node -> reaction nodes it is an input of + pool_members: Dict[str, Set[str]] = {} # pool node -> the member nodes feeding it (specs/048 B1) + split_to: Dict[str, Set[str]] = {} # plain output node -> the variants it is split to (specs/048 B1) + join_members = env_flag("LNG_POOL_JOIN_MEMBERS") + regen_by_protein = pool_regen() == "protein" active_via = pool_active_via() has_st = "stoichiometry" in pathway_logic_network.columns for _, e in pathway_logic_network.iterrows(): @@ -4061,6 +4111,10 @@ def find_pools(pathway_logic_network: pd.DataFrame, reaction_id_map: pd.DataFram acts.setdefault(s, set()).add(t) elif et == "set_member": set_pools_of.setdefault(s, set()).add(t) + if et in ("set_member", "variant_pool"): + pool_members.setdefault(t, set()).add(s) + elif et == "variant_split": + split_to.setdefault(s, set()).add(t) if et == "input" and t in vr: feeds.setdefault(s, set()).add(t) copies: Dict[str, List[str]] = {} @@ -4103,7 +4157,24 @@ def maps(u: str, pe: str) -> bool: for rx, r, a, b, enz in steps: for rx_u in sorted(copies.get(rx, [])): cin = [u for u, st in ins.get(rx_u, {}).items() if st == 1 and maps(u, a) and r in proteins(u)] + if join_members: + # specs/048 B1: a step reading a pool node (variant_pool or + # set_member target) starts from the pool's members that carry + # R, so a cycle closed through the pool (MAPK3 released by + # DUSPs re-entering R-HSA-169291::pool) is visible here as it + # is to the solver. + cin = sorted({m for u in cin for m in ( + [m for m in sorted(pool_members[u]) if r in proteins(m)] + if u in pool_members else [u])}) cout = [u for u, st in outs.get(rx_u, {}).items() if st == 1 and maps(u, b) and r in proteins(u)] + if join_members: + # and a step writing a plain node that variant_split edges hand + # to its variants (a capped reaction's output) ends at those + # variants that carry R: R-HSA-5672980 releases R-HSA-169289, + # split to p-MAPK1 and p-MAPK3. + cout = sorted({m for u in cout for m in ( + [m for m in sorted(split_to[u]) if r in proteins(m)] + if u in split_to else [u])}) if len(cin) != 1 or len(cout) != 1: if len(cin) > 1 or len(cout) > 1: stats["ambiguous_copies"] += 1 @@ -4127,9 +4198,16 @@ def regenerates(path) -> bool: produced: Set[str] = set() for a_u, _, _, _, cp in path: for rx_u in cp: - consumed |= {ent.get(j, "") for j in ins.get(rx_u, {}) - if j != a_u and not prof(j, "small", False) and pe_of(j) not in _UBIQUITIN_STIDS} - produced |= {ent.get(o, "") for o in outs.get(rx_u, {})} + eaten = [j for j in ins.get(rx_u, {}) + if j != a_u and not prof(j, "small", False) and pe_of(j) not in _UBIQUITIN_STIDS + and not (a_u in pool_members.get(j, ()))] + if regen_by_protein: + # specs/048 B2: by the proteins carried, in any form + consumed |= {p for j in eaten for p in proteins(j)} + produced |= {p for o in outs.get(rx_u, {}) for p in proteins(o)} + else: + consumed |= {ent.get(j, "") for j in eaten} + produced |= {ent.get(o, "") for o in outs.get(rx_u, {})} if consumed <= produced: return True local["nonregenerating_paths"] += 1 diff --git a/src/neo4j_connector.py b/src/neo4j_connector.py index 7f40ac2..b7fe15a 100755 --- a/src/neo4j_connector.py +++ b/src/neo4j_connector.py @@ -933,6 +933,76 @@ 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]] = {} + + +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. + """ + 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*0..10]->(x) + WITH DISTINCT x + OPTIONAL MATCH (x)-[:species]->(sp) + OPTIONAL MATCH (x)-[r:hasComponent|hasMember|hasCandidate]->(y) + RETURN x.stId AS x, x.schemaClass AS c, x.displayName AS d, + 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 "") + 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 == "hasComponent"] + members = [y for t, y in kids if t != "hasComponent"] + 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 + 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..fc7b63d 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,9 @@ "LNG_SET_POOL", "LNG_CAP_POOLS", "LNG_POOL_ACTIVE_VIA", + "LNG_POOL_JOIN_MEMBERS", + "LNG_POOL_REGEN", + "LNG_BIND_STOICH", "LNG_VARIANT_NODES", "LNG_DIAGRAM_SET_MEMBER", # Of these three, only LNG_EMIT_ONE_SIDED is a genuine cache gap: it is @@ -219,7 +223,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 +570,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..213220c 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. @@ -103,12 +104,33 @@ def _reference_signature(member: str) -> Tuple[str, ...]: return _sig_cache[member] 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 env_flag("LNG_BIND_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 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}). From a43e20d4bd8ee69c8555b40eae6c52905a5e5dd7 Mon Sep 17 00:00:00 2001 From: Adam Wright <668955+adamjohnwright@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:58:55 -0400 Subject: [PATCH 2/7] specs/048: pathogens.csv; homodimer-aware output binding (LNG_BIND_STOICH) pathogens.csv lists pathogen-derived nodes by the drug rule with species in place of the Drug class (a leaf with a species and none human; a complex if any component is; a set if every member is), for a consumer modelling an uninfected cell. Written always, networks unchanged. LNG_BIND_STOICH=1: the output-binding signature keeps a complex's leaf multiset, as its docstring said it did. Without it a homodimer reads as its monomer and the tie is broken by sorted rank, so R-HSA-5675376 bound p-MAPK1 to the MAPK1 dimer and the p-MAPK1 dimer to MAPK1. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/env_flags.py | 4 +-- src/logic_network_generator.py | 57 ++--------------------------- src/pathway_generator.py | 2 -- tests/test_export_failfast.py | 2 +- tests/test_provenance_exports.py | 62 ++++++++++++++++++++++++++++++++ tests/test_variant_emission.py | 42 ++++++++++++++++++++++ 6 files changed, 109 insertions(+), 60 deletions(-) diff --git a/src/env_flags.py b/src/env_flags.py index 690ae57..d791a02 100644 --- a/src/env_flags.py +++ b/src/env_flags.py @@ -14,7 +14,7 @@ BOOL_FLAGS = { "LNG_BOUNDARY_EXPANSION": True, "LNG_BOUNDARY_HIERARCHY": True, - "LNG_BIND_STOICH": False, # deltasignal specs/048 lever B3 + "LNG_BIND_STOICH": False, # deltasignal specs/048: homodimer binding "LNG_CAP_POOLS": True, # default since deltasignal specs/045 (RAF experimental +14, held-out 0) "LNG_CATALYST_BUNDLE": False, "LNG_COMPLEX_AS_NODE": True, @@ -25,7 +25,6 @@ "LNG_DIAGRAM_SET_MEMBER": False, "LNG_EMIT_ONE_SIDED": True, "LNG_HANDOFF_EDGES": False, - "LNG_POOL_JOIN_MEMBERS": False, # deltasignal specs/048 lever B1 "LNG_SET_EXPAND": True, "LNG_SET_MEMBERS_OR": False, "LNG_SET_POOL": True, @@ -39,7 +38,6 @@ "LNG_HANDOFF_HUB_MAX", # _int_env "LNG_MAX_VARIANTS", # _int_env "LNG_POOL_ACTIVE_VIA", # pool_active_via() - "LNG_POOL_REGEN", # pool_regen() "LNG_PYTHON", # catalog.sh: the interpreter, not a switch }) diff --git a/src/logic_network_generator.py b/src/logic_network_generator.py index a014def..48a21f6 100755 --- a/src/logic_network_generator.py +++ b/src/logic_network_generator.py @@ -512,7 +512,6 @@ def _reject_removed_env() -> None: raise ValueError(f"{name} was removed: {why}") validate_env(removed=_REMOVED_ENV) pool_active_via() - pool_regen() _POOL_ACTIVE_VIA = ("direct", "made_from") @@ -534,24 +533,6 @@ def pool_active_via() -> str: return value -_POOL_REGEN = ("entity", "protein") - - -def pool_regen() -> str: - """LNG_POOL_REGEN (deltasignal specs/048 lever B2): how a multi-step pool - path proves it regenerates what it consumes (specs/039 amendment 5). - ``entity`` (default): every consumed non-R, non-small input is output again - by a step of the path, compared by stable id. ``protein``: every protein a - consumed input carries is output again, in any form, so a partner released - under another stable id (RAS:GTP:activated RAF dimers, R-HSA-5672718 in and - R-HSA-5672712 out) or modified (MAP2K in, p-2S MAP2K out) still counts. - Any other value is an error at startup.""" - value = os.environ.get("LNG_POOL_REGEN", "entity") - if value not in _POOL_REGEN: - raise ValueError(f"LNG_POOL_REGEN must be one of {_POOL_REGEN}, got {value!r}") - return value - - def _register_phase1( vr_entities: Dict[str, tuple], entity_uuid_registry: Dict[tuple, str], @@ -4084,10 +4065,6 @@ def find_pools(pathway_logic_network: pd.DataFrame, reaction_id_map: pd.DataFram acts: Dict[str, Set[str]] = {} # node -> reaction nodes it catalyses or positively regulates set_pools_of: Dict[str, Set[str]] = {} # member node -> the set-pool nodes it feeds (specs/033) feeds: Dict[str, Set[str]] = {} # node -> reaction nodes it is an input of - pool_members: Dict[str, Set[str]] = {} # pool node -> the member nodes feeding it (specs/048 B1) - split_to: Dict[str, Set[str]] = {} # plain output node -> the variants it is split to (specs/048 B1) - join_members = env_flag("LNG_POOL_JOIN_MEMBERS") - regen_by_protein = pool_regen() == "protein" active_via = pool_active_via() has_st = "stoichiometry" in pathway_logic_network.columns for _, e in pathway_logic_network.iterrows(): @@ -4111,10 +4088,6 @@ def find_pools(pathway_logic_network: pd.DataFrame, reaction_id_map: pd.DataFram acts.setdefault(s, set()).add(t) elif et == "set_member": set_pools_of.setdefault(s, set()).add(t) - if et in ("set_member", "variant_pool"): - pool_members.setdefault(t, set()).add(s) - elif et == "variant_split": - split_to.setdefault(s, set()).add(t) if et == "input" and t in vr: feeds.setdefault(s, set()).add(t) copies: Dict[str, List[str]] = {} @@ -4157,24 +4130,7 @@ def maps(u: str, pe: str) -> bool: for rx, r, a, b, enz in steps: for rx_u in sorted(copies.get(rx, [])): cin = [u for u, st in ins.get(rx_u, {}).items() if st == 1 and maps(u, a) and r in proteins(u)] - if join_members: - # specs/048 B1: a step reading a pool node (variant_pool or - # set_member target) starts from the pool's members that carry - # R, so a cycle closed through the pool (MAPK3 released by - # DUSPs re-entering R-HSA-169291::pool) is visible here as it - # is to the solver. - cin = sorted({m for u in cin for m in ( - [m for m in sorted(pool_members[u]) if r in proteins(m)] - if u in pool_members else [u])}) cout = [u for u, st in outs.get(rx_u, {}).items() if st == 1 and maps(u, b) and r in proteins(u)] - if join_members: - # and a step writing a plain node that variant_split edges hand - # to its variants (a capped reaction's output) ends at those - # variants that carry R: R-HSA-5672980 releases R-HSA-169289, - # split to p-MAPK1 and p-MAPK3. - cout = sorted({m for u in cout for m in ( - [m for m in sorted(split_to[u]) if r in proteins(m)] - if u in split_to else [u])}) if len(cin) != 1 or len(cout) != 1: if len(cin) > 1 or len(cout) > 1: stats["ambiguous_copies"] += 1 @@ -4198,16 +4154,9 @@ def regenerates(path) -> bool: produced: Set[str] = set() for a_u, _, _, _, cp in path: for rx_u in cp: - eaten = [j for j in ins.get(rx_u, {}) - if j != a_u and not prof(j, "small", False) and pe_of(j) not in _UBIQUITIN_STIDS - and not (a_u in pool_members.get(j, ()))] - if regen_by_protein: - # specs/048 B2: by the proteins carried, in any form - consumed |= {p for j in eaten for p in proteins(j)} - produced |= {p for o in outs.get(rx_u, {}) for p in proteins(o)} - else: - consumed |= {ent.get(j, "") for j in eaten} - produced |= {ent.get(o, "") for o in outs.get(rx_u, {})} + consumed |= {ent.get(j, "") for j in ins.get(rx_u, {}) + if j != a_u and not prof(j, "small", False) and pe_of(j) not in _UBIQUITIN_STIDS} + produced |= {ent.get(o, "") for o in outs.get(rx_u, {})} if consumed <= produced: return True local["nonregenerating_paths"] += 1 diff --git a/src/pathway_generator.py b/src/pathway_generator.py index fc7b63d..08f920a 100755 --- a/src/pathway_generator.py +++ b/src/pathway_generator.py @@ -43,8 +43,6 @@ "LNG_SET_POOL", "LNG_CAP_POOLS", "LNG_POOL_ACTIVE_VIA", - "LNG_POOL_JOIN_MEMBERS", - "LNG_POOL_REGEN", "LNG_BIND_STOICH", "LNG_VARIANT_NODES", "LNG_DIAGRAM_SET_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..6afaf2f 100644 --- a/tests/test_provenance_exports.py +++ b/tests/test_provenance_exports.py @@ -376,3 +376,65 @@ 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)) + + 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", {}) + 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]}, + {"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"]] diff --git a/tests/test_variant_emission.py b/tests/test_variant_emission.py index d300766..fc0976b 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,43 @@ 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 From 0123fbb60f80eb0c4346f849a99391d1874c5b35 Mon Sep 17 00:00:00 2001 From: Adam Wright <668955+adamjohnwright@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:42:08 -0400 Subject: [PATCH 3/7] LNG_BIND_STOICH on by default: homodimers bind to homodimers (deltasignal specs/048 arm bind: 0 predictions moved, structural checks met) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/env_flags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/env_flags.py b/src/env_flags.py index d791a02..481304d 100644 --- a/src/env_flags.py +++ b/src/env_flags.py @@ -14,7 +14,7 @@ BOOL_FLAGS = { "LNG_BOUNDARY_EXPANSION": True, "LNG_BOUNDARY_HIERARCHY": True, - "LNG_BIND_STOICH": False, # deltasignal specs/048: homodimer binding + "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, From f58ea983912f75ebe1d46182d1fa615604a4ce39 Mon Sep 17 00:00:00 2001 From: Adam Wright <668955+adamjohnwright@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:55:01 -0400 Subject: [PATCH 4/7] LNG_PATHOGEN_PROTEIN (deltasignal specs/048 amendment 2): pathogens.csv lists only entities carrying a pathogen PROTEIN; viral RNA ligands are not held Co-Authored-By: Claude Opus 5.5 (1M context) --- src/env_flags.py | 1 + src/neo4j_connector.py | 16 ++++++++++- src/pathway_generator.py | 1 + tests/test_provenance_exports.py | 49 ++++++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/env_flags.py b/src/env_flags.py index 481304d..09528b6 100644 --- a/src/env_flags.py +++ b/src/env_flags.py @@ -25,6 +25,7 @@ "LNG_DIAGRAM_SET_MEMBER": False, "LNG_EMIT_ONE_SIDED": True, "LNG_HANDOFF_EDGES": False, + "LNG_PATHOGEN_PROTEIN": False, # deltasignal specs/048 amendment 2: pathogens.csv lists protein-carrying entities only "LNG_SET_EXPAND": True, "LNG_SET_MEMBERS_OR": False, "LNG_SET_POOL": True, diff --git a/src/neo4j_connector.py b/src/neo4j_connector.py index b7fe15a..90add0f 100755 --- a/src/neo4j_connector.py +++ b/src/neo4j_connector.py @@ -936,6 +936,10 @@ def derived(s: str, depth: int = 0) -> bool: 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]]: @@ -951,7 +955,13 @@ def get_pathogen_entities(stable_ids) -> Dict[str, Dict[str, str]]: - 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. Viral RNAs (GenomeEncodedEntity, no reference + entity) are then not pathogen-derived: in DDX58/IFIH1 they are the ligand + the pathway senses, and every signalling complex there carries one. """ + 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: @@ -960,8 +970,10 @@ def get_pathogen_entities(stable_ids) -> Dict[str, Dict[str, str]]: MATCH (n:PhysicalEntity {stId: s})-[:hasComponent|hasMember|hasCandidate*0..10]->(x) WITH DISTINCT x OPTIONAL MATCH (x)-[:species]->(sp) + OPTIONAL MATCH (x)-[:referenceEntity]->(re) OPTIONAL MATCH (x)-[r:hasComponent|hasMember|hasCandidate]->(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 """ @@ -975,6 +987,7 @@ def get_pathogen_entities(stable_ids) -> Dict[str, Dict[str, str]]: _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, ([], [], "", "")) @@ -994,7 +1007,8 @@ def derived(s: str, depth: int = 0) -> bool: elif members: out = all(derived(y, depth + 1) for y in members) else: - out = bool(tax) and HUMAN_TAXON not in tax + 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 diff --git a/src/pathway_generator.py b/src/pathway_generator.py index 08f920a..d5260f7 100755 --- a/src/pathway_generator.py +++ b/src/pathway_generator.py @@ -44,6 +44,7 @@ "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 diff --git a/tests/test_provenance_exports.py b/tests/test_provenance_exports.py index 6afaf2f..3ed1e47 100644 --- a/tests/test_provenance_exports.py +++ b/tests/test_provenance_exports.py @@ -438,3 +438,52 @@ def data(_): 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"} From ea178b4c46f7e21da8a466806e56f2691c01c23d Mon Sep 17 00:00:00 2001 From: Adam Wright <668955+adamjohnwright@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:47:05 -0400 Subject: [PATCH 5/7] LNG_PATHOGEN_PROTEIN on by default (deltasignal specs/048 amendment 2 adopted) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/env_flags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/env_flags.py b/src/env_flags.py index 09528b6..dcaa72d 100644 --- a/src/env_flags.py +++ b/src/env_flags.py @@ -25,7 +25,7 @@ "LNG_DIAGRAM_SET_MEMBER": False, "LNG_EMIT_ONE_SIDED": True, "LNG_HANDOFF_EDGES": False, - "LNG_PATHOGEN_PROTEIN": False, # deltasignal specs/048 amendment 2: pathogens.csv lists protein-carrying entities only + "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, From b85d5dc75bbddbdd8d60984890d31d2e61bdd5fa Mon Sep 17 00:00:00 2001 From: Adam Wright <668955+adamjohnwright@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:47:32 -0400 Subject: [PATCH 6/7] tests: pin the species-rule pathogen test to LNG_PATHOGEN_PROTEIN=0; the query test now carries reference classes Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_provenance_exports.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_provenance_exports.py b/tests/test_provenance_exports.py index 3ed1e47..1be9b8e 100644 --- a/tests/test_provenance_exports.py +++ b/tests/test_provenance_exports.py @@ -409,6 +409,7 @@ def test_pathogen_rule_species_leaf_complex_any_set_every(monkeypatch): "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") @@ -420,6 +421,7 @@ def boom(): 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: @@ -430,7 +432,8 @@ 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]}, + {"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()) From 4a914a4c3ccae446aa3520d1b8695b03b5260b22 Mon Sep 17 00:00:00 2001 From: Adam Wright <668955+adamjohnwright@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:53:05 -0400 Subject: [PATCH 7/7] Review of #110: Polymer repeatedUnit read as a component (viral multimers count via their unit); signature cache keyed on LNG_BIND_STOICH; docstring names the real RNA classes; tests for nested stoichiometry, the cache key and Polymers Checked: the Polymer change lists no new entity in any of the 92 catalog pathways (analysis/048/polymer_check.py), so the measured arms stand. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/neo4j_connector.py | 21 ++++++++++++++------- src/variant_emission.py | 11 ++++++----- tests/test_provenance_exports.py | 20 ++++++++++++++++++++ tests/test_variant_emission.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 12 deletions(-) diff --git a/src/neo4j_connector.py b/src/neo4j_connector.py index 90add0f..6db08dd 100755 --- a/src/neo4j_connector.py +++ b/src/neo4j_connector.py @@ -957,9 +957,16 @@ def get_pathogen_entities(stable_ids) -> Dict[str, Dict[str, str]]: 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. Viral RNAs (GenomeEncodedEntity, no reference - entity) are then not pathogen-derived: in DDX58/IFIH1 they are the ligand - the pathway senses, and every signalling complex there carries one. + 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}) @@ -967,11 +974,11 @@ def get_pathogen_entities(stable_ids) -> Dict[str, Dict[str, str]]: if missing: query = """ UNWIND $ids AS s - MATCH (n:PhysicalEntity {stId: s})-[:hasComponent|hasMember|hasCandidate*0..10]->(x) + 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]->(y) + 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, @@ -998,8 +1005,8 @@ def derived(s: str, depth: int = 0) -> bool: 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 == "hasComponent"] - members = [y for t, y in kids if t != "hasComponent"] + 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: diff --git a/src/variant_emission.py b/src/variant_emission.py index 213220c..5af806d 100644 --- a/src/variant_emission.py +++ b/src/variant_emission.py @@ -94,22 +94,23 @@ 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 - if vk.is_complex(member) and env_flag("LNG_BIND_STOICH"): + 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 diff --git a/tests/test_provenance_exports.py b/tests/test_provenance_exports.py index 1be9b8e..15bdc41 100644 --- a/tests/test_provenance_exports.py +++ b/tests/test_provenance_exports.py @@ -490,3 +490,23 @@ def data(_): 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 fc0976b..e3b21d2 100644 --- a/tests/test_variant_emission.py +++ b/tests/test_variant_emission.py @@ -195,3 +195,31 @@ def test_homodimer_binding_off_reproduces_the_crossed_pairing(monkeypatch): (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") +