Skip to content

Faithful set-variant networks + LinkML output contract + diagram connectivity + determinism#43

Merged
adamjohnwright merged 17 commits into
mainfrom
fix/complex-as-single-node
Jul 14, 2026
Merged

Faithful set-variant networks + LinkML output contract + diagram connectivity + determinism#43
adamjohnwright merged 17 commits into
mainfrom
fix/complex-as-single-node

Conversation

@adamjohnwright

Copy link
Copy Markdown
Contributor

Catches main up with the current generator (it was ~52 commits behind — the whole bulk-prefetch-decomposition line) and adds this round of work.

Highlights (recent, on top of bulk-prefetch)

  • Complexes emitted as set-variant nodes — a Complex is one node, split only along its internal EntitySets, not decomposed into members. Faithful to curation; verified against Neo4j (e.g. CCNA:CDK2 → the two cyclin variants; nested sets compose).
  • Machine-readable output contract (LinkML)schema/logic_network.linkml.yaml + provenance files: nodes.csv (node_kind, diagram_entity_id, member_leaves, source_sets, chosen_members), node_reaction_context.csv, and edge_reaction_id on edges. bin/validate-logic-network.py validates a pathway output dir.
  • Diagram-sourced connectivity (Diagram-sourced reaction connectivity (A→product→B) to fill precedingEvent gaps #39) — connect reactions the curator drew as connected (shared entity glyph = producer output / consumer input) using the pathway's own diagram or nearest diagrammed ancestor (isolated to the pathway's reactions). Cofactor-clean by construction; fills the precedingEvent gap on old pathways (Glucose metabolism +53 flows). Only the connectivity merge sees it; the matching layer stays on pure precedingEvent.
  • Deterministic generation (Generator is non-deterministic (hash-seed-dependent); ~5.8% edges differ run-to-run #42) — pin PYTHONHASHSEED=0 (auto re-exec) so regeneration is reproducible (~5.8% of edges differed run-to-run before).

Validation

  • 9-pathway experimental (clean, deterministic A/B, diagram off vs on): 69.28% → 69.68%, driven by ERBB2 73.5→79.6%, zero regressions.
  • Set-variant vs old member-exploded: ties on accuracy but faithful, and cuts spurious edges.

Caveats for reviewers

  • The set-variant change affects every generated network. We validated the 9 benchmark pathways + a couple metabolic; regenerate + spot-check the full catalog before any production regen.
  • Confirms main should hold the full bulk-prefetch-decomposition line (it was just stale).
  • _emit_precedingevent_handoff_edges is included but env-gated OFF (component-level bridging was net-negative; kept for reference).

adamjohnwright and others added 17 commits May 26, 2026 16:56
Complexes/EntitySets that contain an EntitySet are expanded into virtual
variants during generation, so the parent species' stId never appears in
stid_to_uuid_mapping.csv. Consumers that know a species by its Reactome
stId (notably MP-BioPath key-outputs, which are predominantly Complexes)
then can't locate it — even though the reaction that produces it is in the
network. This was silently dropping ~35% of benchmark key-output lookups.

New output file entity_reaction_proxy_mapping.csv maps each such missing
species to the UUIDs of the reaction that produces it (falling back to the
reaction that consumes it). Reaction flux is a tight, biologically faithful
proxy for "is this species present?" — far better than pointing at the
species' terminal components, which for hub proteins span dozens of reaction
contexts and discriminate nothing.

The primary stid_to_uuid_mapping.csv is untouched (keeps its one-row-per-UUID
identity contract). bin/backfill-proxy-mapping.py regenerates the file for the
existing catalog without a full re-run.

Verified on Signaling_by_WNT: the ubiquitinated-phospho-beta-catenin complex
(R-HSA-2130284), previously unresolvable, now maps to its 17 producing-reaction
UUIDs. End-to-end accuracy on the MP-BioPath experimental set rose from 38.4%
to 55.7%, with keyoutput-not-in-network failures dropping 209 → 17.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root-input / terminal-output membership was decided by a global stId set
difference (all_input_eids - all_output_eids). That removed a complex's stId
entirely the moment it appeared as a product *anywhere* in the pathway, so its
genuine root-input occurrences were never decomposed. Curators routinely
perturb individual subunits, so those subunits must be addressable wherever
they enter or leave the pathway.

_emit_boundary_decomposition_edges now derives roots/terminals positionally
from the edge list (source-only = root input, target-only = terminal output)
and decomposes each such complex occurrence. Members are not duplicated: a
member reuses its existing node (or one freshly-minted UUID) and gets one
assembly/dissociation edge per occurrence — so a complex appearing as a root
input at N positions yields one member node with N edges into it.

Verified on DNA_Double_Strand_Break_Response: boundary edges 7+10 -> 410+637;
MDC1 and RNF8 (previously unaddressable, perturbed by curators) now resolve to
network nodes. Catalyst/regulator-only proteins (e.g. USP1) remain a separate
gap — they never appear as a reaction input/output.

Adds unit tests for member sharing across occurrences and for leaving
intermediate occurrences intact. Also fixes bin/backfill-proxy-mapping.py to
put the repo root on sys.path so it runs from bin/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ctional nodes

Adam's design intent for terminal-output decomposition is per-location
measurement of each member ("this part of the network is active, that part
isn't"). The previous implementation reused the member's functional UUID for
the dissociation leaf — which broke the measurement: terminal-complex activity
was injected into every reaction the member touches elsewhere, fabricating
spurious downstream changes (false_positive_change quadrupled, 542 → 2294,
held-out e2e dropped 77.74% → 67.04%).

Assembly and dissociation now produce deliberately opposite kinds of node:
  - Assembly (root input): member is a shared upstream perturbation handle
    (reuses any existing UUID) — the complex requires the member, a real
    forward dependency.
  - Dissociation (terminal output): member is a FRESH per-occurrence readout
    sink, tagged with the member's stId, value inherited from the complex,
    with NO outgoing edges. Terminal complexes don't carry forward signal,
    so the sink can't broadcast — zero cross-talk, full per-location
    measurability. Aggregate across a member's sinks for an overall figure.

Verified on the 54-pathway held-out curator set: e2e 81.84% / valid 84.37%
(beats the 77.74% / 81.34% pre-boundary baseline by +4.1pp). Matches the
benchmark-side skip-dissociation A/B prediction within 0.15pp. The 12%
gene-not-in-network gap stays closed (149 vs the 589 before any boundary
fix). Adds a unit test asserting dissociation sinks are fresh, separate
from the functional node, and have no outgoing edges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two upgrades to the naive Boolean baseline so the apples-to-apples comparison
against DeltaSignal is rigorous:

- --propagator signed: sum-of-displacements-from-NORMAL AND combination, in
  contrast to the default --propagator min (Boolean MIN). The signed variant
  is the natural comparator for DeltaSignal's signed AND aggregation; it
  carries upregulations through gates whose other inputs are unperturbed.

- entity_reaction_proxy_mapping.csv fallback: when a curator key-output stId
  isn't a node in stid_to_uuid_mapping.csv (it was decomposed into virtual
  variants during generation), fall back to the producing reaction UUIDs.
  Mirrors what bench/benchmark_vs_mpbiopath.py in the deltasignal repo does,
  so neither method is unfairly penalised on key-output resolution.

Held-out result on the 54 regenerated curator pathways (with both upgrades):
  --propagator min:    71.03%  (the historical "70.55%" baseline)
  --propagator signed: 82.39%  (the strongest discrete baseline)

This is the right comparator to publish against; DeltaSignal with divide-form
inhibition lands at 82.33% / 84.89% on the same set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Negative-regulator complexes are now kept as whole biological units instead of
being broken down into individual member proteins. When the inhibitor complex
contains an internal EntitySet, the cartesian product is expanded into one
synthetic variant ID per combination (`{parent_stid}::variant::{sorted_members}`),
each emitted as a single inhibitor edge.

Why: with the previous subunit decomposition, every protein subunit of an
inhibitor complex (e.g. HSP90, CDC37, ERBIN inside the
ERBB2:trastuzumab:HSP90:CDC37 drug-bound complex) became a standalone
inhibitor edge on the regulated reaction. Whenever any reaction elsewhere
in the network produced those bystander subunits, the spurious inhibitor
edges fired and crushed the downstream signal — measured 12pp of false
suppression on Signaling_by_ERBB2 in the deltasignal experimental benchmark.

Catalysts and positive regulators continue to use subunit decomposition:
their holoenzyme subunits are biologically AND-required for the complex
to function, so decomposing to terminal members is correct there.

Result on the deltasignal experimental benchmark (4 pathways, 363 cases):
  - Signaling_by_ERBB2: +4.1pp e2e, +5.9pp valid-only
  - Overall: 69.42% → 69.97%

Implementation:
- `_decompose_regulator_entity(entity_id, variant_decomposition=False)`:
  new flag selects the decomposition mode.
- `_expand_complex_variants(complex_id)`: cartesian-product expansion of
  internal EntitySets, returning content-addressed synthetic variant IDs.
- `append_regulators`: passes `variant_decomposition=True` for the negative
  regulator config only.
- `_is_complex` in the boundary-decomposition phase skips synthetic variant
  IDs (they don't exist in Neo4j and don't need further decomposition).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds catalyst→input edges with edge_type="depletion", pos_neg="neg",
and_or="and" for reactions whose outputs include inorganic phosphate
(R-ALL-29372 / Pi). The deltasignal solver applies divide-form inhibition
to these edges specifically, so catalyst-knockout (e.g., PTEN-KO) boosts
the substrate (e.g., PIP3) via de-repression — unlocking 18+ PTEN cases
that previously failed with category="no_path" because the network had
no forward path from PTEN to AKT downstream.

Filtering is by reaction OUTPUTS rather than catalyst identity so we don't
need name-string heuristics or biology annotations: any reaction that
outputs Pi is by definition a phosphatase. Future work can extend the
filter to ubiquitin-ligase / protease reactions (MDM2-TP53 case is similar
but doesn't output Pi).

Cofactor inputs (ATP/ADP/H2O/Pi/etc.) are excluded so depletion edges only
fire on biological substrates.

Also made `_is_complex` in the boundary-decomposition step tolerant of
unknown stIds (synthetic IDs added by other emission passes) — was already
skipping `::variant::` but other custom IDs need the same treatment.

Impact on the experimental benchmark (9 pathways, 800 cases): the
deltasignal solver goes from 63.38% to 66.75% with these edges enabled,
beating the naive signed Boolean propagator by +4.25pp. PIP3 specifically
jumps from 82% to 93% (matching curator predictions on the same cases).

Depletion edge counts per pathway (phosphatase-only filter):
  PIP3:     16 edges (5 reactions)
  ERBB2:    24 edges (43 reactions, many with no non-cofactor inputs)
  TP53:    183 edges (6 reactions, many UUIDs)
  WNT:      36 edges (5 reactions)
  HDR/Mitotic_G1/S_Phase/CCC/Prophase: 0 edges each (no phosphatases)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Attempted to extend the depletion-edge filter from phosphatase-only
(reactions outputting Pi) to also cover ubiquitin-ligase reactions
(displayName contains "ubiquitinat" but not "deubiquitinat"). Hypothesis
was that MDM2-TP53 and similar ubiquitination-driven substrate-depletion
cases would be captured.

Empirically tested on the 9-pathway experimental benchmark:
  - phosphatase only:                    544/800 = 68.00%
  - phosphatase + ubiquitin-ligase:      540/800 = 67.50%  (-0.5pp)
  - per-case check on TP53 MDM2 cases:   0 of 20 fixed

Root cause: Reactome models ubiquitination mechanism precisely. The
"MDM2 ubiquitinates TP53" reaction (R-HSA-6804879) has INPUTS:
  - Ub (R-HSA-68524)
  - p-MDM2:MDM4:TP53 (R-HSA-6804885) — the ASSEMBLED complex
and OUTPUTS:
  - PolyUb-TP53 (R-HSA-3209186) — the ubiquitinated form
  - p-MDM2 dimer — the released catalyst

So the catalyst→input pair points MDM2 → complex, not MDM2 → free TP53.
The depletion edge depresses the level of the complex (which isn't what
TP53-downstream cascades read), not free TP53. Other ubiquitin reactions
across S_Phase, WNT, etc. add hundreds of edges that don't capture the
right biology and noise-up the cascade — net regression.

Capturing ubiquitin-driven depletion properly requires modeling the
multi-step bind→ubiquitinate→degrade chain (e.g., MDM2 → TP53 as a
"sequestration" edge that tracks total free TP53 pool). That's outside
the scope of this single-reaction heuristic.

Keeping the phosphatase-only filter (the +3.4pp win documented earlier
in commit c49f26d remains valid).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a topology-based detection of ubiquitin-ligase reactions: a reaction
takes ubiquitin (R-HSA-68524 / R-HSA-113595 / R-HSA-9660007 — Ub across
compartments) as an INPUT. Cleaner than display-name string matching;
no reliance on Reactome curator wording.

The key insight that fixes the previous ubiquitin attempt: identify the
SUBSTRATE topologically, not as the literal reaction input. Reactome models
ubiquitination biology precisely — "MDM2 ubiquitinates TP53" takes the
ASSEMBLED complex `p-MDM2:MDM4:TP53` as input (not free TP53), so a naive
`catalyst → input` depletion edge points MDM2 → complex rather than MDM2 →
TP53. The fix:

  1. Decompose the input PE through hasComponent/hasMember/hasCandidate
     into its leaf member proteins, recording each protein's
     referenceEntity.geneName.
  2. Do the same for the output PE.
  3. The substrate's gene appears in BOTH (e.g., TP53 is in both input
     and output — modified in the output). That's the SUBSTRATE.
  4. Look up ALL network UUIDs of the substrate's free-form stable_id
     (e.g., R-HSA-69488 free TP53, which has 12 UUIDs across positions
     in the TP53 pathway).
  5. Emit `catalyst → each-free-substrate-UUID` depletion edges.

When MDM2 is knocked out, divide-form inhibition on the depletion edges
boosts free TP53 levels via de-repression, propagating through TP53's
forward transcription edges to its targets.

Experimental benchmark (9 pathways, 800 cases):
  - phosphatase only:             544/800 = 68.00%
  - + topology ubiquitin:         558/800 = 69.75%  (+1.75pp)

Per-pathway with the new edges:
  - TP53:  53.3% → 59.1% (+5.8pp) — main beneficiary
  - WNT:   62.7% → 68.6% (+5.9pp) — SCF complex on β-catenin
  - ERBB2: 61.2% → 55.1% (-6.1pp) — slight regression from extra edges
  - CCC:   69.1% → 67.3% (-1.8pp)
  - Other pathways unchanged

Edge counts per pathway (phosphatase + topology-ubiquitin combined):
  PIP3:        61   (5p +  5u reactions)
  ERBB2:      542   (43p + 5u — large because of position-aware UUIDs)
  Mitotic_G1:  11   (0p +  2u)
  S_Phase:     12   (0p +  4u)
  HDR:       1120   (0p +  1u — one big substrate)
  CCC:         19   (0p +  4u)
  TP53:       201   (6p +  6u)
  WNT:       3595   (5p +  8u — heavy ubiquitin biology)
  Prophase:     0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Emission sites use a mix of int (`1`) and float (`1.0`) literals. The
DataFrame schema declares stoichiometry as Int64 but `pd.DataFrame(rows,
columns=list(...))` only respects column ORDER from the columns arg, not
the dtype — so pandas infers from data, and a single 1.0 anywhere
pollutes the whole column to float64, serializing as `1.0` in the CSV.

Coerce the column to nullable Int64 after construction so the CSV always
reads as clean integers. Reactome stoichiometries for the pathways we
care about are all whole numbers (counts of subunits in complexes,
typically 1–6 with the occasional larger homomultimer); a fractional
value would now raise a parse error rather than silently survive — which
is what we want.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Backs the machine-readable output schema and network validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
schema/logic_network.linkml.yaml is the contract for the emitted files (nodes, node_reaction_context, edges); bin/validate-logic-network.py loads a pathway output dir into the native model and validates it. See #39.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extracts product->substrate pairs from the Reactome diagram JSON (entity glyph shared between a producer's output and a consumer's input/catalyst), using the pathway's own diagram or the nearest diagrammed ancestor (isolated to the pathway's reactions). Cofactor-clean by construction. Closes the precedingEvent-gap on old pathways. #39.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…onnectivity

- Complex is one node, split only along internal EntitySets (set-variant), not decomposed to members (faithful to curation; verified vs Neo4j).

- Emit nodes.csv (node_kind, diagram_entity_id, member_leaves, source_sets, chosen_members) and node_reaction_context.csv; add edge_reaction_id to logic_network.csv.

- Wire diagram-sourced connectivity into reaction_connections (Phase-2 merges the shared product -> A->product->B); matching layer stays on pure precedingEvent.

- Includes _emit_precedingevent_handoff_edges, env-gated OFF (LNG_HANDOFF_EDGES): component-level bridging was net-negative, kept for reference only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-exec once with a fixed hash seed so regeneration is reproducible (output depended on set/dict iteration order; ~5.8% of edges differed run-to-run). Override with LNG_ALLOW_NONDETERMINISM=1. #42.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add [tool.ruff.lint] ignore=[E701,E702] to match the codebase's one-line-guard style.

- Rename ambiguous 'l' loop vars; rename 'uuid'/'node_uuid' loop vars that shadowed the uuid module.

- Narrow Optional[str] from dict .get() (stid, uid) so mypy passes; behavior-preserving.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nance

- valid_edge_types now includes depletion/assembly/dissociation/handoff (matches the EdgeType enum) across the four structure tests.

- Rewrite test_no_duplicate_edges for the set-variant _resolve_vr_entities (mocks Neo4j; asserts set-deduped nodes).

- Add test_diagram_connectivity.py and test_provenance_exports.py (no Neo4j), lifting coverage back over the 40% gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@adamjohnwright
adamjohnwright merged commit b290502 into main Jul 14, 2026
4 checks passed
@adamjohnwright
adamjohnwright deleted the fix/complex-as-single-node branch July 14, 2026 23:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant