diff --git a/bench/analysis/experimental_gap.py b/bench/analysis/experimental_gap.py new file mode 100644 index 0000000..b86d7c5 --- /dev/null +++ b/bench/analysis/experimental_gap.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Where DeltaSignal loses to MP-BioPath on the experimental axis, case by case. + +MP-BioPath's per-case calls are published in +mp-biopath-pathways/analysis_results/MP_BioPathReactomePathwayAccuracy-Tests-updated.tsv +(`mp_biopath_state` against `experimental_value`, 849 cases, 643 correct). +This joins them with an arm's experimental_cases.tsv and splits the cases into +both right / only MP-BioPath / only DeltaSignal / neither, by pathway, +perturbation, our failure category and (truth, our call). + +A case that does not join, or whose experimental value disagrees with our +expected label, is an error, not a skip: a silent mis-join would make the gap +look like whatever the join happened to keep. + +Usage: experimental_gap.py ARM_RESULTS_DIR [--mpb TSV] [--out cases.tsv] +""" +from __future__ import annotations + +import argparse +import collections +import csv +import sys +from pathlib import Path + +DEFAULT_MPB = (Path.home() / "gitroot" / "mp-biopath-pathways" / "analysis_results" + / "MP_BioPathReactomePathwayAccuracy-Tests-updated.tsv") +LABEL = {"0": "DOWN", "1": "NORMAL", "2": "UP"} + + +def load_mpb(path: Path) -> dict[tuple, tuple[str, str]]: + """(pathway, gene, direction, key_output) -> (experimental, mp-biopath state).""" + out: dict[tuple, tuple[str, str]] = {} + with path.open(newline="") as handle: + for r in csv.DictReader(handle, delimiter="\t"): + if r["experimental_value"] in ("NA", ""): + continue + gene, direction = r["scenario"].rsplit("_", 1) + key = (r["pathway_name"], gene, direction, r["keyoutput_id"]) + if key in out: + raise SystemExit(f"duplicate MP-BioPath row {key}") + out[key] = (r["experimental_value"], r["mp_biopath_state"]) + return out + + +def split(mpb: dict, ours_rows: list[dict]) -> list[dict]: + rows = [] + for o in ours_rows: + key = (o["pathway"], o["gene"], o["direction"], o["key_output"]) + if key not in mpb: + raise SystemExit(f"case not in MP-BioPath's table: {key}") + truth, them = mpb[key] + if truth != o["expected"]: + raise SystemExit(f"experimental value {truth} != our expected {o['expected']} for {key}") + us = o["predicted"] if o["valid"] == "1" else "invalid" + rows.append({"pathway": key[0], "gene": key[1], "direction": key[2], "key_output": key[3], + "truth": truth, "mpbiopath": them, "deltasignal": us, + "quadrant": ("MPB" if them == truth else "-") + "/" + ("DS" if us == truth else "-"), + "category": o.get("category", "")}) + missing = set(mpb) - {(r["pathway"], r["gene"], r["direction"], r["key_output"]) for r in rows} + if missing: + raise SystemExit(f"{len(missing)} MP-BioPath cases have no row in the arm, e.g. {sorted(missing)[0]}") + return rows + + +def report(rows: list[dict]) -> str: + out = [] + q = collections.Counter(r["quadrant"] for r in rows) + mpb_right = q["MPB/DS"] + q["MPB/-"]; ds_right = q["MPB/DS"] + q["-/DS"] + out.append(f"cases {len(rows)}: MP-BioPath {mpb_right}, DeltaSignal {ds_right}, gap {mpb_right - ds_right:+d}") + out.append(f" both right {q['MPB/DS']}, only MP-BioPath {q['MPB/-']}, " + f"only DeltaSignal {q['-/DS']}, neither {q['-/-']}; either {len(rows) - q['-/-']}") + pw = collections.defaultdict(collections.Counter) + for r in rows: + pw[r["pathway"]][r["quadrant"]] += 1 + out.append("per pathway: only-MPB | only-DS | net") + for p, c in sorted(pw.items(), key=lambda x: -(x[1]["MPB/-"] - x[1]["-/DS"])): + out.append(f" {p[:40]:40s} {c['MPB/-']:4d} | {c['-/DS']:4d} | {c['MPB/-'] - c['-/DS']:+4d} (n={sum(c.values())})") + lost = [r for r in rows if r["quadrant"] == "MPB/-"] + out.append("only-MPB by our category: " + str(collections.Counter(r["category"] for r in lost).most_common())) + out.append("only-MPB by (truth, our call): " + str(collections.Counter( + (LABEL.get(r["truth"]), LABEL.get(r["deltasignal"], r["deltasignal"])) for r in lost).most_common())) + pert = collections.Counter((r["pathway"][:24], r["gene"], "KO" if r["direction"] == "0" else "UP") for r in lost) + out.append("only-MPB by perturbation: " + str(pert.most_common(15))) + return "\n".join(out) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("arm", type=Path) + ap.add_argument("--mpb", type=Path, default=DEFAULT_MPB) + ap.add_argument("--out", type=Path) + a = ap.parse_args() + with (a.arm / "experimental_cases.tsv").open(newline="") as handle: + ours = list(csv.DictReader(handle, delimiter="\t")) + rows = split(load_mpb(a.mpb), ours) + print(report(rows)) + if a.out: + with a.out.open("w", newline="") as handle: + w = csv.DictWriter(handle, fieldnames=list(rows[0]), delimiter="\t") + w.writeheader(); w.writerows(rows) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bench/analysis/mpbiopath_network_adapter.py b/bench/analysis/mpbiopath_network_adapter.py index 9027259..9ac2569 100644 --- a/bench/analysis/mpbiopath_network_adapter.py +++ b/bench/analysis/mpbiopath_network_adapter.py @@ -44,6 +44,7 @@ import argparse import csv +import re import sys from pathlib import Path @@ -80,6 +81,10 @@ def catalog_ids(catalog: Path) -> dict[str, str]: mapping: dict[str, str] = {} for directory in sorted(p for p in catalog.iterdir() if p.is_dir()): name = directory.name + if re.fullmatch(r"R-HSA-\d+", name): + # Current generator naming: the directory IS the stable id. + mapping[name] = name + continue if "_R-HSA-" not in name: continue prefix, _, numeric = name.rpartition("_R-HSA-") @@ -100,8 +105,11 @@ def case_pathway_names(cases_tsv: Path) -> dict[str, str]: """ mapping: dict[str, str] = {} with cases_tsv.open(newline="") as handle: - for row in csv.DictReader(handle, delimiter="\t"): - pid = (row.get("pathway_id") or "").strip() + lines = (ln for ln in handle if not ln.startswith("#")) + for row in csv.DictReader(lines, delimiter="\t"): + # A cases dump (pathway_id, pathway_name) or the versioned + # pathway list bench/catalog_pathways.tsv (id, pathway_name). + pid = (row.get("pathway_id") or row.get("id") or "").strip() name = (row.get("pathway_name") or "").strip() if pid and name: mapping[pid] = name @@ -165,7 +173,7 @@ def main() -> int: missing_mpb.append(f"{prefix} ({stable})") continue edges = parse_network(source) - target = args.out / f"{prefix}_{stable}" + target = args.out / (stable if prefix == stable else f"{prefix}_{stable}") write_catalog_dir(target, edges) written.append((target.name, len(edges))) diff --git a/bench/analysis/test_experimental_gap.py b/bench/analysis/test_experimental_gap.py new file mode 100644 index 0000000..af94909 --- /dev/null +++ b/bench/analysis/test_experimental_gap.py @@ -0,0 +1,40 @@ +"""experimental_gap.py: the join must be exact and the quadrants right.""" +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent)) +from experimental_gap import report, split # noqa: E402 + + +def case(p, g, d, k, pred, exp, valid="1", cat="pass"): + return {"pathway": p, "gene": g, "direction": d, "key_output": k, "predicted": pred, + "expected": exp, "valid": valid, "category": cat} + + +MPB = {("P", "A", "0", "1"): ("0", "0"), ("P", "A", "0", "2"): ("2", "1"), + ("P", "B", "2", "1"): ("2", "2"), ("Q", "C", "0", "9"): ("1", "2")} + + +def test_quadrants_and_invalid_counts_as_wrong(): + rows = split(MPB, [case("P", "A", "0", "1", "2", "0"), # only MPB + case("P", "A", "0", "2", "2", "2"), # only DS + case("P", "B", "2", "1", "1", "2", valid="0"), # invalid -> only MPB + case("Q", "C", "0", "9", "0", "1")]) # neither + assert [r["quadrant"] for r in rows] == ["MPB/-", "-/DS", "MPB/-", "-/-"] + text = report(rows) + assert "MP-BioPath 2, DeltaSignal 1, gap +1" in text + assert "either 3" in text + + +def test_a_label_disagreement_is_an_error(): + with pytest.raises(SystemExit): + split(MPB, [case("P", "A", "0", "1", "0", "2")]) + + +def test_a_case_missing_on_either_side_is_an_error(): + with pytest.raises(SystemExit): + split(MPB, [case("P", "A", "0", "1", "0", "0")]) # 3 MPB cases unmatched + with pytest.raises(SystemExit): + split({}, [case("P", "A", "0", "1", "0", "0")]) diff --git a/bench/analysis/test_mpbiopath_adapter.py b/bench/analysis/test_mpbiopath_adapter.py index ee68b93..ea286ac 100644 --- a/bench/analysis/test_mpbiopath_adapter.py +++ b/bench/analysis/test_mpbiopath_adapter.py @@ -15,7 +15,7 @@ sys.path.insert(0, str(Path(__file__).parent)) from mpbiopath_network_adapter import ( # noqa: E402 - CONJUNCTION, POLARITY, UUID_PREFIX, case_pathway_names, parse_network, + CONJUNCTION, POLARITY, UUID_PREFIX, case_pathway_names, catalog_ids, parse_network, write_catalog_dir, ) @@ -89,9 +89,22 @@ def check(label, actual, expected): check("case name bridge", case_pathway_names(cases), {"R-HSA-453279": "Mitotic_G1-G1_S_phases"}) + # Current generator naming (bare stable-id directories) and the versioned + # pathway list as the name bridge; both were unreadable until 2026-10-09, + # so the adapter silently adapted nothing. + cat = tmp / "catalog" + (cat / "R-HSA-453279").mkdir(parents=True) + (cat / "Old_Name_R-HSA-69620").mkdir() + check("bare and legacy catalog dirs", catalog_ids(cat), + {"R-HSA-453279": "R-HSA-453279", "Old_Name": "R-HSA-69620"}) + plist = tmp / "catalog_pathways.tsv" + plist.write_text("# comment\nid\tpathway_name\nR-HSA-453279\tMitotic_G1-G1_S_phases\n") + check("pathway list as name bridge", case_pathway_names(plist), + {"R-HSA-453279": "Mitotic_G1-G1_S_phases"}) + if failures: print("FAILED:") for f in failures: print(" -", f) sys.exit(1) -print(f"all adapter assertions passed ({12} checks)") +print(f"all adapter assertions passed ({14} checks)") diff --git a/specs/047-experimental-gap/research.md b/specs/047-experimental-gap/research.md new file mode 100644 index 0000000..a57c04c --- /dev/null +++ b/specs/047-experimental-gap/research.md @@ -0,0 +1,287 @@ +# 047: Where the experimental gap to MP-BioPath is, case by case + +**Status:** analysis in progress (2026-10-09). No behaviour change yet. + +## Why + +On the experimental axis we score 594/849 (69.96%), and MP-BioPath scores +643/849 (75.74%). Curator-axis work has gained 0.3–0.7pp per feature while +experimental drifted from 627 to 594 across generator changes. Experimental +is the axis measured against biology, so it should be the target, not a +guardrail. This spec decomposes the gap before any lever is designed. + +## Data + +MP-BioPath's per-case calls are published in +`mp-biopath-pathways/analysis_results/MP_BioPathReactomePathwayAccuracy-Tests-updated.tsv` +(`mp_biopath_state` against `experimental_value`). +`bench/analysis/experimental_gap.py` joins them with an arm's +`experimental_cases.tsv`: +- all 849 cases join; +- the experimental value equals our expected label on all 849; +- 643 MP-BioPath-correct reproduces. + +A missing case or a label disagreement is an error, not a skip. + +## Decomposition (canonical: build `20261004-1959_6990015`, solver 5979e48) + +| | Cases | +|---|---| +| Both right | 540 | +| **Only MP-BioPath right** | **103** | +| Only DeltaSignal right | 54 | +| Neither | 152 | + +The two are complementary: either one right covers 697/849 (82.1%). + +**Per pathway** (only-MP-BioPath | only-DeltaSignal | net): + +| Pathway | Only MP-BioPath | Only DeltaSignal | Net | +|---|---|---|---| +| TP53 | 32 | 18 | +14 | +| Mitotic G1 | 20 | 11 | +9 | +| PIP3 | 18 | 9 | +9 | +| Cell Cycle Checkpoints | 8 | 1 | +7 | +| RAF | 9 | 3 | +6 | +| HDR | 4 | 0 | +4 | +| Mitotic Prophase | 2 | 0 | +2 | +| S Phase | 1 | 1 | 0 | +| ERBB2 | 3 | 4 | −1 | +| WNT | 6 | 7 | −1 | + +The gap is spread across pathways, not one. + +**The 103 only-MP-BioPath cases:** +- **By our failure category:** propagator_missed 47, no_path 31, + false_positive_change 24, readout not in network 1. +- **By (truth, our call):** + + | Truth → our call | Cases | + |---|---| + | DOWN → UP | 30 | + | UP → NORMAL | 22 | + | DOWN → NORMAL | 15 | + | NORMAL → UP | 14 | + | UP → DOWN | 11 | + | NORMAL → DOWN | 10 | + + **41 are sign inversions**: the signal reaches the readout backwards. +- **By perturbation (top):** + + | Perturbation | Cases | Note | + |---|---|---| + | PTEN UP (PIP3) | 9 | all inverted | + | RBL2 KO (Mitotic G1) | 7 | | + | PDPK1 KO (PIP3) | 7 | inverted | + | DAXX KO + UP (TP53) | 11 | | + | CDKN2A UP + KO (TP53) | 8 | | + | RB1 KO | 4 | | + | NF1 KO | 4 | | + | BRCA1 KO | 4 | | + +## Trace 1: PTEN overexpression inverts (9 cases) + +The readouts read 100× (UP) after PTEN overexpression; truth is DOWN. They +also read 100× after PTEN knockout, so they do not depend on PTEN's direction. + +1. The bench pins all three PTEN roots at 80×: PTEN gene (R-HSA-5632949), + K48polyUb-PTEN (R-HSA-8948777) and PTEN mRNA (R-HSA-2318746). +2. PTEN translation, R-HSA-8944497 ("PTEN mRNA translation is negatively + regulated by microRNAs"), has **12 negative regulators**. Each is a + `miR-x RISC:PTEN mRNA` complex, and each reads 64.5×. +3. Under `divide` they multiply: 80 / 64.5¹² ≈ 1.5e-20. **Overexpressing PTEN + shuts off PTEN protein.** The PTEN copy that depletes PI(4,5)P2 reads 0, + PIP2 is de-repressed to 6.7×, PIP3 rises, and a positive loop rails AKT to + 100×. +4. The specs/022 self-inhibitor rule is on (227 slots flagged in the + pathway), and the complexes contain PTEN mRNA. The rule still does not + fire, because it requires the shared input node to reach the inhibitor. + PTEN mRNA exists as **two silo copies**: + - root bb6f1223 feeds only translation; + - produced copy 337e82aa (from transcription of the pinned gene) feeds the + 12 RISC-binding reactions. + + Translation reads one copy, its inhibitors are built from the other. +5. With only the mRNA pinned, the readout reads 0.00065 (DOWN, correct). + +Biologically, RISC binding sequesters a fraction of the mRNA. More mRNA +means proportionally more RISC:mRNA, not less translation. The failure is +the specs/012/022 double count, unmasked by copy siloing. + +**Candidate levers** (not designed yet): +- (a) the self-inhibitor rule matches the shared input by entity across copies + rather than by node reachability; +- (b) the generator joins the transcription product to the translation input + (the copy silo). + +Either is a pre-registered arm, measured with experimental as the primary +axis. + +## Trace 2: PDPK1 knockout inverts through a SARS-CoV-2 inhibitor (7 cases) + +PDPK1 knockout should lower phospho-AKT; truth is DOWN, and we read 100×. + +1. "PDPK1 phosphorylates AKT at T308" (R-HSA-198270) has a negative + regulator, `N:M:PDPK1` (R-HSA-9755778): the SARS-CoV-2 N dimer and M + protein bound to PDPK1. Reactome added it after 2019, so MP-BioPath's + networks lack it. +2. Knocking out PDPK1 zeroes that complex, the reaction is de-repressed to the + ceiling, and the AKT positive loop + (R-HSA-198270 ↔ R-HSA-2317313 ↔ R-HSA-2317314) rails to 100×. +3. In a human cell without the virus the inhibitor does not exist. Holding it + at baseline makes it a constant factor, which cancels in fold-change. That + is the specs/032 drug rule's mechanism applied to pathogen-derived nodes. + +Across the ten experimental pathways, the containment tables name only three +non-human entities, all SARS-CoV-2: N dimer, a modified N, and M. A pathogen +rule would be narrow. + +Separately, the catalyst `p-S-AKT:PDPK1:PIP3` (R-HSA-2317313) reads 100× under +a PDPK1 knockout. Its PDPK1 comes in through the loop, not from the pinned +copy. That is the same copy-silo pattern as trace 1, not yet traced to a +line. + +**Candidate lever:** `DS_PATHOGEN_MODE=inert`, or the generator listing +non-human-derived nodes the way `drugs.csv` lists drugs. Pre-registered and +measured with experimental as the primary axis. + +## Network or propagator? The control + +**Arms.** Solver 3a8d59d (`src/` identical to 0525296). +- `mpbnet`: our propagator on MP-BioPath's own 2019 networks (build + `20261009-2051_mpbnet2019`, 85 pathways, via + `bench/analysis/mpbiopath_network_adapter.py`). +- `canon_tablefree`: the canonical networks under the same solver settings. + +Both run with `DS_CYCLE_MODE=off`, `DS_SELF_INHIBITOR_WEIGHT=off` and +`DS_SELF_INHIBITOR_LEAVES=0`, because MP-BioPath's networks carry no pool or +containment tables. The canonical default arm (`5979e48`) is shown too. + +| Pathway | n | MP-BioPath | Canonical | Canonical, table-free | **Ours on MPB networks** | +|---|---|---|---|---|---| +| TP53 | 257 | 182 | 168 | 167 | 179 | +| PIP3 | 200 | 177 | 168 | 177 | 142 | +| Mitotic G1 | 89 | 39 | 30 | 30 | 41 | +| Cell Cycle Checkpoints | 55 | 46 | 39 | 31 | 47 | +| WNT | 51 | 36 | 37 | 31 | 36 | +| RAF | 49 | 45 | 39 | 17 | 49 | +| ERBB2 | 49 | 41 | 42 | 42 | 41 | +| HDR | 48 | 40 | 36 | 36 | 36 | +| Mitotic Prophase | 26 | 21 | 19 | 20 | 22 | +| S Phase | 25 | 16 | 16 | 16 | 17 | +| **Total** | 849 | **643** | **594** | **567** | **610** | + +**Findings:** +1. **The gap is network construction, not the propagator, in every + pathway except PIP3.** On MP-BioPath's networks our propagator matches or + beats MP-BioPath in 8 of 10 pathways: RAF 49 vs 45, Mitotic G1 41 vs 39, + Cell Cycle Checkpoints 47 vs 46, Prophase, S Phase, WNT, ERBB2, and TP53 + 179 vs 182. +2. **Under identical solver settings, our networks cost 43 cases** + (567 vs 610). The largest single pathway is RAF, −32 (17 vs 49); the + rest is spread over Mitotic G1, Cell Cycle Checkpoints, TP53 and WNT. +3. **Our network-specific rules recover 27 of the 43** (567 → 594). Cycle + balance does most of it in RAF (17 → 39). In PIP3 they cost 9 + (177 → 168). +4. **PIP3 is the exception.** Our propagator on their PIP3 network scores 142 + against their 177; on our own PIP3 network it scores 168–177. So PIP3's + shortfall on their network is a propagator matter, irrelevant to our + networks. +5. **Choosing per pathway** between canonical and their networks gives 638, + against MP-BioPath's 643. Nothing in the propagator stands between us and + MP-BioPath's experimental accuracy; the networks do. + +**Where to look:** +- The network share lives in RAF (39 vs 49), Mitotic G1 (30 vs 41), Cell Cycle + Checkpoints (39 vs 47) and TP53 (168 vs 179). That is 41 cases across 4 + pathways. +- specs/034 already compared RAF's networks: MP-BioPath's ~1,800 hand edits, + mostly reversal-enzyme inhibitions of interconversion cycles, 187 absent + from ours. +- **Next:** for each discordant case in these four pathways, diff the two + networks along the gene → readout route (both are keyed by Reactome ids) + and classify what MP-BioPath's network has that ours lacks, or vice versa. + +## Trace 3: RAF, a recycled scaffold amplifies itself (NRAS KO, and likely NF1 KO) + +These are the 10 RAF cases our propagator gets right on MP-BioPath's network +and wrong on ours: + +| Perturbation | Cases | Truth → ours | +|---|---|---| +| NRAS KO | 3 | DOWN → UP | +| NF1 KO | 4 | UP → DOWN or no change | +| ARAF / BRAF / RAF1 KO → R-HSA-5674341 | 3 | DOWN → no change | + +**NRAS KO → p-T,Y MAPK dimers (R-HSA-1268261) reads 63×.** The trace: +1. "RAF/MAPK scaffolds" (R-HSA-5672717, a CandidateSet of ARRB1, ARRB2, + focal-adhesion and others) is an input to "MAP2Ks and MAPKs bind to the + activated RAF complex" (R-HSA-5672972). +2. RAF phosphorylates MAP2K (R-HSA-5672978), MAP2Ks phosphorylate MAPKs + (R-HSA-5672973), and "Dissociation of RAS:RAF complex" (R-HSA-5672980) + **releases the scaffold**. Reactome curates that: it is an output of + R-HSA-5672980 in Neo4j. +3. The released scaffold re-enters step 1 at the complex's own fold. This + positive loop rails the cascade to 63–100× whatever RAS does, so NRAS KO + reads UP. +4. MP-BioPath's RAF network has the same scaffold-member → R-HSA-5672972 + edges, but its R-HSA-5672980 outputs only R-HSA-5672712, 169289 and + 5672721. **No scaffold release**, so no recycling loop, and RAS + perturbations reach the readouts. Whether 2019 Reactome lacked the + release or it was a hand cut, their network is acyclic here. + +**The class: a conserved carrier.** The scaffold is bound at one step, +carried unchanged through two modification steps, and released at a +fourth. Its total is conserved, so it cannot amplify itself. +- specs/039 balance pools model exactly that conservation, but the + generator's pool detection finds interconversion cycles of a modified + species. It does not find a carrier bound and released across a + multi-step chain: RAF has one pool of 3 rows, none touching the scaffold. +- The blanket alternatives were refuted: specs/035 (all conserved loop + inputs held at baseline, experimental −122, it held PIP3) and specs/040 + rule A. + +**Candidate lever:** extend pool detection to multi-step carrier cycles (a +species bound by one reaction and released unchanged by a later reaction in +the same chain), solved by the existing balance rule. Narrower than +specs/035, and consistent with D1 identity. Measured with experimental as +the primary axis. + +## Trace 4: Mitotic G1, released E2F copies are not the ones that drive expression (no-path class) + +In Mitotic G1, 20 cases are right on MP-BioPath's network and wrong on ours +(9 the reverse). Most are no-path in ours: CCND1 UP (5), RB1 KO (3+), +SRC UP (3), CDK4, CDKN1B, CDKN2A, PPP2R1A. + +**CCND1 UP → CDT1 (R-HSA-68536):** +- Forward reach from the CCND1 root is 459 nodes. Backward reach from the + readout is 97 nodes. They share **no node**. +- They do share 7 **entities** as different, unjoined copies: E2F1, E2F4, + E2F5, TFDP1, TFDP2, RBL1, RBL2. +- CDK4/6:CCND phosphorylates RB/RBL and releases E2F:DP. The released copies + are not the copies that "CDT1 gene expression is stimulated by E2F1" + reads. + +This is the dissociation-sink silo of specs/015 and 031: a released subunit +beside an unfed twin. Blanket bridging of it was measured harmful four times +on the curator axis (fan-out median 139). MP-BioPath's hand-built network +connects it. + +`route_diff.py` (analysis dir) can't match MP-BioPath's set nodes (Cyclin D, +CDK4/CDK6, sets of complexes) to our member-decomposed nodes, so its ABSENT +marks there are artefacts. The reach comparison above is the reliable test. + +## Mechanism classes so far + +| Class | Example | Cases (approx.) | Prior measurements | +|---|---|---|---| +| Copy silo hides a self-inhibitor | PTEN UP | 9 | specs/022 rule needs node reachability | +| Pathogen-derived regulator, added after 2019 | PDPK1 KO | 7 | specs/032 drug rule is the analogue | +| Multi-step carrier cycle amplifies itself | RAF NRAS/NF1 KO | ~10 | specs/035 and 040 blanket rules refuted | +| Released transcription factor in a silo | G1 CCND1/RB1/SRC/CDK4 | ~16 | specs/015/031 bridging refuted on curator | +| Over-coupling (false change) | TP53 DAXX | 12 | — | + +Every class is a network-construction issue. Each was seen before on the +curator axis, where blanket versions failed. What has never been done is to +target them with experimental as the primary axis and a rule narrow enough +to match MP-BioPath's hand edits.