Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,23 @@ env vars only override for benchmark sweeps:
nodes (a Reactome `Drug`, a complex containing one, a set that is all drugs),
listed by the generator's `drugs.csv`, held at baseline like cofactors, for a
cell without the drug. The solve reports `drug_rule` and `drugs_held`.
- `DS_PATHOGEN_MODE=inert` (**default since 2026-10-10**, specs/048 amendment
2) | `propagate`: nodes listed in the generator's `pathogens.csv` are held
at baseline, as drugs are, for a cell without the pathogen. An observation
still wins.
- Under the generator's `LNG_PATHOGEN_PROTEIN` (default on), the list holds
only entities that carry a pathogen PROTEIN (an EWAS on a
`ReferenceGeneProduct`): a protein leaf, a complex with such a component,
or a set whose members all are, e.g. SARS-CoV-2 N:M:PDPK1.
- Viral RNAs are not listed. They are the ligand DDX58/IFIH1 senses, and
holding them broke 12 curator cases, net −11 (arm `path`).
- Builds from before 2026-10-10 have no `pathogens.csv`, so the bench refuses
them under the default; re-run one with `--server DS_PATHOGEN_MODE=propagate`
(the specs/047 MP-BioPath-network run included).
- Against `propagate`: experimental +7 (all PDPK1 knockout), held-out +1,
nothing broken.
- The solve reports `pathogen_rule` and `pathogens_held`. A bundle without
the table reports `inert: no pathogen table`, and the bench refuses it.
- `DS_SET_POOL_MODE=product` (default) | `extreme` | `geomean` | `mean` | `max`
(specs/033): how a set POOL node (the generator's `LNG_SET_POOL`: one node
per set-valued catalyst or regulator, fed by `set_member` edges) combines its
Expand Down Expand Up @@ -512,6 +529,14 @@ for the last feature that touched it rather than re-deriving from the code.
matches or beats it in 8 of 10 pathways: **the gap is network
construction** (RAF, Mitotic G1, Cell Cycle Checkpoints, TP53), traced to
five mechanism classes. `bench/analysis/experimental_gap.py` reproduces it.
- `specs/048-experimental-levers/` — two levers from 047.
- **Pathogen-derived nodes held at baseline: adopted**, as the protein-only
rule of amendment 2. The first rule also held viral RNA and broke 12
cases in DDX58/IFIH1 (net −11).
- **Homodimer binding fix (`LNG_BIND_STOICH`): adopted** on faithfulness;
it moves no prediction.
- Pools that share intermediates (the RAF knockout→UP class) are
unresolved; that work is on `wip/048-mapk-pools`.
- `specs/009-solver-defaults/` — the one-variable-at-a-time re-measurement
behind the `DS_*` defaults above (cited in that section too).
- `.specify/memory/constitution.md` — project principles the specs are
Expand All @@ -530,15 +555,15 @@ other seven execute code and print output.

| file | assertions | note |
|---|---|---|
| `test/test_config_validation.jl` | 212 | |
| `test/test_config_validation.jl` | 215 | |
| `test/test_loop_elasticity.jl` | 173 | + 1 `@test_broken` |
| `test/test_propagator_invariants.jl` | 121 | |
| `test/test_loop_pool.jl` | 81 | |
| `test/test_solver_determinism.jl` | 126 | |
| `test/test_and_curves.jl` | 71 | |
| `test/test_scc_break_roles.jl` | 56 | |
| `test/test_self_inhibition.jl` | 82 | specs/022, added 2026-09-25 |
| `test/test_drugs.jl` | 59 | specs/032, added 2026-09-26 |
| `test/test_drugs.jl` | 125 | specs/032, added 2026-09-26; pathogens (specs/048) 2026-10-10 |
| `test/test_set_pool.jl` | 79 | specs/033, added 2026-09-26; `variant_pool` (specs/046) 2026-10-09 |
| `test/test_conserved.jl` | 15 | specs/035, added 2026-09-26 |
| `test/test_cycles.jl` | 236 | specs/039, added 2026-09-27 |
Expand Down
21 changes: 20 additions & 1 deletion bench/benchmark_vs_mpbiopath.py
Original file line number Diff line number Diff line change
Expand Up @@ -1023,7 +1023,8 @@ def run_pathway(pathway_id: str, pathway_name: str, gene_to_stids_cache=None,
"cofactor_stids": parsed.get("cofactor_stids"),
# specs/032: without it an edge-drop arm under DS_DRUG_MODE=inert
# reached the solver with no drug table (review of the branch).
"drug_stids": parsed.get("drug_stids")}
"drug_stids": parsed.get("drug_stids"),
"pathogen_stids": parsed.get("pathogen_stids")}
# Use the server-cached network by id (fast: send only observations per
# solve). But if SKIP_EDGE_TYPES modified the edges above, the cached
# network is stale, so send the full modified payload instead.
Expand Down Expand Up @@ -1065,11 +1066,19 @@ def run_pathway(pathway_id: str, pathway_name: str, gene_to_stids_cache=None,
if ds_result and ds_result.get("drug_rule") == "inert: no drug table":
raise SystemExit(f"{pathway_name}: DS_DRUG_MODE=inert but the network has no drugs.csv "
"(a build that predates it), so no drug is held (specs/032).")
if ds_result and ds_result.get("pathogen_rule") == "inert: no pathogen table":
raise SystemExit(f"{pathway_name}: DS_PATHOGEN_MODE=inert but the network has no pathogens.csv "
"(a build that predates it), so nothing is held (specs/048).")
if ds_result:
SOLVE_TALLY["solves"] += 1
SOLVE_TALLY["converged"] += bool(ds_result.get("converged"))
SOLVE_TALLY[f"drug_rule={ds_result.get('drug_rule', 'unknown')}"] += 1
SOLVE_TALLY["drugs_held"] += int(ds_result.get("drugs_held") or 0)
SOLVE_TALLY[f"pathogen_rule={ds_result.get('pathogen_rule', 'unknown')}"] += 1
SOLVE_TALLY["pathogens_held"] += int(ds_result.get("pathogens_held") or 0)
# how many listed stable ids this solve's network carried: a run whose
# pathways list none (most do) holds nothing under the default by design
SOLVE_TALLY["pathogens_listed"] += len(parsed.get("pathogen_stids") or [])
SOLVE_TALLY[f"conserved_rule={ds_result.get('conserved_rule', 'unknown')}"] += 1
if ds_result.get("cycle_rule") == "balance: no pool table":
raise SystemExit(f"{pathway_name}: DS_CYCLE_MODE=balance but the network has no pools.csv "
Expand Down Expand Up @@ -1383,6 +1392,16 @@ def main():
if "inert" in rules and SOLVE_TALLY["drugs_held"] == 0:
# An inert arm that held nothing measured the default model.
raise SystemExit("DS_DRUG_MODE=inert but no solve held a drug node; the arm is the control.")
prules = sorted(k.split("=", 1)[1] for k in SOLVE_TALLY if k.startswith("pathogen_rule="))
print(f"\nPathogens: rule {'/'.join(prules) or 'unknown'}, {SOLVE_TALLY['pathogens_held']} node-solves held (specs/048)")
# inert is the default since specs/048 amendment 2, and 83 of 92 pathways list
# no pathogen, so a subset run that holds nothing is expected. Refuse only when
# networks DID list pathogens and none was held: the listing matched nothing.
if "inert" in prules and SOLVE_TALLY["pathogens_held"] == 0:
if SOLVE_TALLY["pathogens_listed"]:
raise SystemExit("DS_PATHOGEN_MODE=inert and the networks list pathogens, but no solve held one; "
"the listing matched no node.")
print(" (no pathway in this run lists a pathogen, so inert holds nothing: identical to propagate here)")
yrules = sorted(k.split("=", 1)[1] for k in SOLVE_TALLY if k.startswith("cycle_rule="))
yc = sorted(k.split("=", 1)[1] for k in SOLVE_TALLY if k.startswith("cycle_carriers="))
print(f"\nCycles: rule {'/'.join(yrules) or 'unknown'}, {SOLVE_TALLY['cycle_pools_solved']} pool-solves, "
Expand Down
26 changes: 26 additions & 0 deletions bench/test_bench_failfast.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,29 @@ def test_a_pathway_without_data_is_still_a_plain_skip(tmp_path, monkeypatch):
outcomes = {"PathA": ok_result,
"PathB": lambda n: {"status": "no_network", "name": n}}
run_main(tmp_path, monkeypatch, outcomes) # returns normally: not a failure


def _pathogen_outcome(listed, held):
def outcome(name):
bench.SOLVE_TALLY["pathogen_rule=inert"] += 1
bench.SOLVE_TALLY["pathogens_listed"] += listed
bench.SOLVE_TALLY["pathogens_held"] += held
return ok_result(name)
return outcome


def test_default_inert_on_pathways_listing_no_pathogen_is_not_refused(tmp_path, monkeypatch, capsys):
# specs/048: inert is the default and 83 of 92 pathways list no pathogen, so a
# subset run that holds nothing is the expected case, not "the arm is the control".
run_main(tmp_path, monkeypatch, {"PathA": _pathogen_outcome(0, 0)})
assert "no pathway in this run lists a pathogen" in capsys.readouterr().out


def test_inert_refused_when_listed_pathogens_match_no_node(tmp_path, monkeypatch):
with pytest.raises(SystemExit) as e:
run_main(tmp_path, monkeypatch, {"PathA": _pathogen_outcome(5, 0)})
assert "matched no node" in str(e.value.code)


def test_inert_holding_listed_pathogens_runs(tmp_path, monkeypatch):
run_main(tmp_path, monkeypatch, {"PathA": _pathogen_outcome(5, 3)})
6 changes: 5 additions & 1 deletion cli/deltasignal.jl
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ function execute_parse_command(args)
"containment" => network_containment_json(network),
# And for DS_DRUG_MODE (specs/032); null when the bundle has no list.
"drug_stids" => drug_stids_json(network),
# And for DS_PATHOGEN_MODE (specs/048).
"pathogen_stids" => pathogen_stids_json(network),
"pools" => pools_json(network),
"set_mappings" => Dict(set_id => Dict(
"original_set_id" => mapping.original_set_id,
Expand Down Expand Up @@ -394,7 +396,9 @@ function execute_solve_command(args)
containment = containment_from_json(get(network_json, "containment", nothing))
drug_stids = drug_stids_from_json(get(network_json, "drug_stids", nothing))
pools = pools_from_json(get(network_json, "pools", nothing))
network = ReactionNetwork(nodes, edges, set_mappings, cofactor_stids, containment, drug_stids, pools)
pathogen_stids = pathogen_stids_from_json(get(network_json, "pathogen_stids", nothing))
network = ReactionNetwork(nodes, edges, set_mappings, cofactor_stids, containment, drug_stids, pools,
pathogen_stids)
println("✓ Network loaded: $(length(nodes)) nodes, $(length(edges)) edges")

# Load observations CSV
Expand Down
10 changes: 10 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ are what the solver needs to run the default model; send them back with an
inline network. `drug_stids` (specs/032, additive) lists the bundle's
drug-derived stable ids, or is `null` when the bundle has no `drugs.csv`; send
it back too, or `DS_DRUG_MODE=inert` reports `"inert: no drug table"`.
`pathogen_stids` (specs/048, additive) is the same for the bundle's
`pathogens.csv`. Without it, the default `DS_PATHOGEN_MODE=inert` reports
`"inert: no pathogen table"` and holds nothing.
`pools` (specs/039, additive) is the interconversion-pool table, or `null`
when the bundle has no `pools.csv`. Send it back with an inline network, or the
default `DS_CYCLE_MODE=balance` reports `"balance: no pool table"` and loops
Expand Down Expand Up @@ -166,6 +169,13 @@ Response:
(default), `"inert"`, or `"inert: no drug table"`, and how many drug-derived
nodes this solve held at baseline. Under `inert` a drug participates at fold
1.0 and never carries a perturbation; an explicit observation still wins.
- **`pathogen_rule`** / **`pathogens_held`** (additive, specs/048): `"inert"`
(default since 2026-10-10), `"propagate"`, or `"inert: no pathogen table"`,
and how many pathogen-derived nodes this solve held at baseline. A node is
held as a drug is under `inert`.
The built-in sample network has no pathogen table, so a bodyless
`/api/parse` → `/api/solve` reports `"inert: no pathogen table"`; nothing
is wrong with that solve.
- **`self_inhibitor_rule`** (additive): `"on"`, `"off"`, or `"inert: no containment table"`. The last means the solve did NOT run the default model, because the network came without a containment table. `/api/parse` returns `containment`; send it back with a POSTed network to keep the rule active.
- **`cycle_rule`**, **`cycle_pools_solved`**, **`cycle_pools_multistep`**,
**`cycle_carriers`**, **`cycle_carriers_held`** (additive, specs/039):
Expand Down
1 change: 1 addition & 0 deletions scripts/run_arm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ for gt in ("curator", "experimental"):
m = re.search(r"^Pinned: .*$", text, re.M); rec[f"{gt}_pinned"] = m.group(0) if m else None
m = re.search(r"^Converged: .*$", text, re.M); rec[f"{gt}_converged"] = m.group(0) if m else None
m = re.search(r"^Drugs: .*$", text, re.M); rec[f"{gt}_drugs"] = m.group(0) if m else None
m = re.search(r"^Pathogens: .*$", text, re.M); rec[f"{gt}_pathogens"] = m.group(0) if m else None
m = re.search(r"^Conserved: .*$", text, re.M); rec[f"{gt}_conserved"] = m.group(0) if m else None
m = re.search(r"^Cycles: .*$", text, re.M); rec[f"{gt}_cycles"] = m.group(0) if m else None
json.dump(rec, open(os.path.join(out, "ARM.json"), "w"), indent=2)
Expand Down
Loading
Loading