oncodsl / tests /test_api_airgap.py
govindbalki's picture
Upload folder using huggingface_hub
0fff343 verified
Raw
History Blame Contribute Delete
31.5 kB
"""Airgap assertions on the new Lab endpoints.
We POST a tiny synthetic run, drain the SSE stream, then fetch /runs/{id}
and /runs/{id}/result. Every payload must contain ONLY opaque gene IDs
— no human-readable gene symbols. We also confirm `POST /evaluate`
returns only the supplied IDs' symbols (never dumps the whole map).
"""
from __future__ import annotations
import json
import re
import time
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
import api.app as api_app
from airgap.seal import _read_sealed
KNOWN_SYMBOLS = [
"MLH1", "MSH2", "MSH6", "PMS2", # MMR
"CD8A", "GZMA", "PRF1", # immune
"TP53", "KRAS", "BRCA1", "BRCA2", # famous cancer genes
"EGFR", "PIK3CA", "APC",
]
_OPAQUE_RE = re.compile(r"\bg\d{4,6}\b")
def _has_no_symbols(text: str) -> tuple[bool, list[str]]:
"""Returns (ok, offenders) — offenders is list of symbols found."""
found = [s for s in KNOWN_SYMBOLS if re.search(rf"\b{s}\b", text)]
return (not found), found
@pytest.fixture(autouse=True)
def _isolated_data_cache(monkeypatch):
"""Replace the API's data prep with a small synthetic matrix so the test
runs in milliseconds and is independent of data/processed/*.parquet."""
rng = np.random.default_rng(0)
n_samples, n_features, n_informative = 80, 60, 6
cols = [f"g{i+1:05d}" for i in range(n_features)]
sample_ids = pd.Index([f"s{i}" for i in range(n_samples)], name="sample_id")
half = n_samples // 2
y_bin = np.array([1] * half + [0] * (n_samples - half))
X = rng.normal(loc=0.0, scale=1.0, size=(n_samples, n_features))
X[:half, :n_informative] += 2.5
M = pd.DataFrame(X, index=sample_ids, columns=cols)
y_cont = rng.normal(loc=10.0, scale=3.0, size=n_samples)
Xc = rng.normal(loc=5.0, scale=1.0, size=(n_samples, n_features))
for j in range(n_informative):
Xc[:, j] = -0.8 * y_cont + rng.normal(scale=0.5, size=n_samples)
Mc = pd.DataFrame(Xc, index=sample_ids, columns=cols)
# Synthetic clinical (named fields only — stage / age) and the
# "other" target so engine_v2's Effect / Associate / FitApply have
# something to chew on if a v2 worker picks them.
clinical = pd.DataFrame(
{
"stage": rng.choice(["I", "II", "III", "IV"], size=n_samples),
"age": rng.uniform(40, 80, size=n_samples),
},
index=sample_ids,
)
# HNSC fixture: binary HPV target, same shape so HPV smoke-runs
# converge in milliseconds like the MSI fixture.
y_hpv = np.array([1] * (n_samples // 2) + [0] * (n_samples - n_samples // 2))
Xh = rng.normal(loc=0.0, scale=1.0, size=(n_samples, n_features))
Xh[: n_samples // 2, :n_informative] += 2.5
Mh = pd.DataFrame(Xh, index=sample_ids, columns=cols)
def fake_prep(target: str, dataset: str = "coadread"):
if dataset == "coadread" and target == "msi":
return M, y_bin, clinical, {"tmb": y_cont}
if dataset == "coadread" and target == "tmb":
return Mc, y_cont, clinical, {"msi": y_bin}
if dataset == "coadread" and target == "none":
# Unsupervised: engine sees no y; labels travel via extra_labels
# so the worker can compute the post-hoc alignment afterwards.
return M, None, clinical, {"msi": y_bin.astype(float), "tmb": y_cont}
if dataset == "hnsc" and target == "hpv":
return Mh, y_hpv, clinical, {}
if dataset == "hnsc" and target == "none":
return Mh, None, clinical, {"hpv": y_hpv.astype(float)}
raise ValueError((dataset, target))
monkeypatch.setattr(api_app, "_prepare_lab_data", fake_prep)
api_app.RUN_STORE.clear()
yield
def _post_tiny_run(
client: TestClient,
objective_spec: dict,
*,
prefilter_n: int | None = 20,
engine: str = "v1",
dataset: str = "coadread",
) -> str:
body = {
"objective_spec": objective_spec,
"params": {
"generations": 3, "population": 12, "genes_per_set": 4,
"max_sets": 2, "lambda": 0.005, "seed": 0,
"prefilter_n": prefilter_n, "permutations": 5,
},
"engine": engine,
"dataset": dataset,
}
r = client.post("/runs", json=body)
assert r.status_code == 200, r.text
return r.json()["run_id"]
def _drain_stream(client: TestClient, run_id: str, max_seconds: float = 20.0) -> list[str]:
"""Drain the SSE stream until done/error or timeout. Returns raw lines."""
out: list[str] = []
saw_terminal = False
with client.stream("GET", f"/runs/{run_id}/stream") as resp:
assert resp.status_code == 200, resp.text
deadline = time.time() + max_seconds
for line in resp.iter_lines():
if time.time() > deadline:
break
out.append(line)
if line.startswith("event:") and (
line.endswith(": done") or line.endswith(": error")
):
saw_terminal = True
# After seeing terminal event, read its data line + blank then stop.
if saw_terminal and line == "":
break
return out
def _wait_until_done(client: TestClient, run_id: str, timeout: float = 20.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
r = client.get(f"/runs/{run_id}")
assert r.status_code == 200
st = r.json()["status"]
if st in ("done", "error"):
if st == "error":
raise AssertionError(f"run errored: {r.json()['error']}")
return
time.sleep(0.05)
raise AssertionError(f"run {run_id} did not finish within {timeout}s")
@pytest.mark.parametrize("spec", [
{"target": "msi", "metric": "auroc"},
{"target": "tmb", "metric": "correlation", "direction": "neg"},
{"target": "none", "metric": "structure"},
])
@pytest.mark.parametrize("prefilter_n", [20, None])
def test_lab_payloads_contain_only_opaque_ids(spec, prefilter_n):
client = TestClient(api_app.app)
# Engine v2 is required for the unsup objective.
engine = "v2" if spec["target"] == "none" else "v1"
run_id = _post_tiny_run(client, spec, prefilter_n=prefilter_n, engine=engine)
_wait_until_done(client, run_id)
# Status + accumulated log payload.
r = client.get(f"/runs/{run_id}")
text = json.dumps(r.json())
ok, offenders = _has_no_symbols(text)
assert ok, f"/runs/{run_id} leaked symbols: {offenders}"
# Final result payload.
r = client.get(f"/runs/{run_id}/result")
text = json.dumps(r.json())
ok, offenders = _has_no_symbols(text)
assert ok, f"/runs/{run_id}/result leaked symbols: {offenders}"
# Confirm there ARE opaque IDs present (sanity). For an unsup tiny
# run the winner may bypass Select entirely (e.g. Reduce(M, mean)),
# so we only assert this for label-driven objectives.
if spec.get("target") != "none":
assert _OPAQUE_RE.search(text), "expected opaque g##### IDs in result"
# And confirm the prefilter_N field reflects whether the prefilter ran.
r = client.get(f"/runs/{run_id}")
log_payload = r.json()
assert "log" in log_payload
def test_lab_stream_replays_after_done_with_only_opaque_ids():
client = TestClient(api_app.app)
run_id = _post_tiny_run(client, {"target": "msi", "metric": "auroc"})
_wait_until_done(client, run_id)
lines = _drain_stream(client, run_id)
text = "\n".join(lines)
ok, offenders = _has_no_symbols(text)
assert ok, f"SSE stream leaked symbols: {offenders}"
assert any(ln.startswith("event:") and ln.endswith(": done") for ln in lines), \
f"no terminal event in lines: {lines[-10:]}"
def test_evaluate_returns_only_supplied_ids():
client = TestClient(api_app.app)
sealed = _read_sealed()
id_to_symbol = sealed["id_to_symbol"]
# Pick a handful of real IDs at random.
sample_ids = list(id_to_symbol.keys())[:6]
r = client.post("/evaluate", json={
"gene_ids": sample_ids,
"reference_set": "MMR",
})
assert r.status_code == 200, r.text
body = r.json()
assert len(body["revealed"]) == len(sample_ids)
assert all(row["id"] in sample_ids for row in body["revealed"])
# The endpoint did not leak the rest of the map.
text = json.dumps(body)
others = [s for s in id_to_symbol.values()
if s not in [row["symbol"] for row in body["revealed"]]]
# check none of the "other" map symbols appear in the response.
assert all(o not in text for o in others[:50])
def test_evaluate_rejects_unknown_reference_set():
client = TestClient(api_app.app)
r = client.post("/evaluate", json={
"gene_ids": ["g00001"],
"reference_set": "nonexistent",
})
assert r.status_code == 400
assert "reference_set" in r.text
def test_post_runs_rejects_unsupported_objective():
client = TestClient(api_app.app)
r = client.post("/runs", json={
"objective_spec": {"target": "survival", "metric": "auroc"},
"params": {"generations": 3, "population": 12, "genes_per_set": 4,
"max_sets": 2, "lambda": 0.005, "seed": 0,
"prefilter_n": 20, "permutations": 5},
})
assert r.status_code == 400
def test_legacy_endpoints_still_present():
"""Smoke-check that /health, /run, /result, /reveal are still mounted."""
client = TestClient(api_app.app)
r = client.get("/health")
assert r.status_code == 200
assert "artefacts" in r.json()
def test_unsupervised_run_emits_posthoc_alignment():
"""An unsupervised run must produce a posthoc block on the result
(alignment to MSI / TMB computed AFTER the GP), and the engine
payload must still be airgap-clean."""
client = TestClient(api_app.app)
run_id = _post_tiny_run(
client,
{"target": "none", "metric": "structure"},
prefilter_n=None,
engine="v2",
)
_wait_until_done(client, run_id, timeout=30.0)
res = client.get(f"/runs/{run_id}/result").json()
assert res["objective_spec"]["target"] == "none"
assert res["permutation_summary"]["null_kind"] == "random_vector_programs"
assert "posthoc" in res, res
p = res["posthoc"]
# On the synthetic fixture the held-out subset is < 30 patients, so
# the AUROC may be None — we accept that. What MUST be present:
assert "msi_auroc" in p
assert "tmb_abs_spearman" in p
assert p["n_holdout"] >= 1
# Airgap: no symbol leaks anywhere in the payload.
text = json.dumps(res)
ok, offenders = _has_no_symbols(text)
assert ok, f"unsup result leaked symbols: {offenders}"
# Iterative-discovery chain prerequisite: the winner block must
# carry full-cohort scores so a follow-up run can residualise
# against them.
winning = res["winning"]
assert isinstance(winning.get("full_scores"), list) and len(winning["full_scores"]) > 0
assert isinstance(winning.get("full_sample_ids"), list)
assert len(winning["full_sample_ids"]) == len(winning["full_scores"])
def test_residualisation_chain_runs_and_stays_airgap_clean():
"""An unsupervised chain: first run produces Axis 1 + full scores;
second run posts with residualize_against=[axis1] and must succeed,
its payload stays opaque-only, and the engine builds a fresh winner
in the residualised feature space."""
client = TestClient(api_app.app)
axis1_id = _post_tiny_run(
client,
{"target": "none", "metric": "structure"},
prefilter_n=None,
engine="v2",
)
_wait_until_done(client, axis1_id, timeout=30.0)
r = client.post("/runs", json={
"objective_spec": {"target": "none", "metric": "structure"},
"params": {
"generations": 3, "population": 12, "genes_per_set": 4,
"max_sets": 2, "lambda": 0.005, "seed": 1,
"prefilter_n": None, "permutations": 5,
},
"engine": "v2",
"residualize_against": [axis1_id],
})
assert r.status_code == 200, r.text
axis2_id = r.json()["run_id"]
_wait_until_done(client, axis2_id, timeout=30.0)
res2 = client.get(f"/runs/{axis2_id}/result").json()
assert res2["objective_spec"]["target"] == "none"
# Engine still produced a Vector-only program.
assert "Associate" not in res2["winning"]["program_repr"]
# Airgap: opaque-only payload.
text = json.dumps(res2)
ok, offenders = _has_no_symbols(text)
assert ok, f"axis-2 result leaked symbols: {offenders}"
# The new winner has its own full-cohort scores so the chain can
# extend to a third axis.
assert len(res2["winning"]["full_scores"]) > 0
def test_residualize_against_unknown_id_returns_400():
client = TestClient(api_app.app)
r = client.post("/runs", json={
"objective_spec": {"target": "none", "metric": "structure"},
"params": {
"generations": 3, "population": 12, "genes_per_set": 4,
"max_sets": 2, "lambda": 0.005, "seed": 0,
"prefilter_n": None, "permutations": 5,
},
"engine": "v2",
"residualize_against": ["zzzzzzzzzzzz"],
})
assert r.status_code == 400
assert "zzzzzzzzzzzz" in r.text
def test_residualize_against_works_on_supervised_targets():
"""Peel-off ('Find next axis') now works for supervised objectives
too. Validation rejects priors that don't share the new run's
(dataset, target); the unsupervised-only gate is gone."""
client = TestClient(api_app.app)
# Unknown prior id → 400 (the chain is empty / invalid).
r = client.post("/runs", json={
"objective_spec": {"target": "msi", "metric": "auroc"},
"params": {
"generations": 3, "population": 12, "genes_per_set": 4,
"max_sets": 2, "lambda": 0.005, "seed": 0,
"prefilter_n": None, "permutations": 5,
},
"engine": "v2",
"residualize_against": ["doesnotmatter"],
})
assert r.status_code == 400
assert "doesnotmatter" in r.text
# Now spin up a real MSI Axis 1, then post Axis 2 with
# residualize_against=[axis1]; should succeed and produce a fresh
# winner with its own full_scores.
axis1_id = _post_tiny_run(
client, {"target": "msi", "metric": "auroc"}, engine="v2",
prefilter_n=None,
)
_wait_until_done(client, axis1_id, timeout=30.0)
r = client.post("/runs", json={
"objective_spec": {"target": "msi", "metric": "auroc"},
"params": {
"generations": 3, "population": 12, "genes_per_set": 4,
"max_sets": 2, "lambda": 0.005, "seed": 1,
"prefilter_n": None, "permutations": 5,
},
"engine": "v2",
"residualize_against": [axis1_id],
})
assert r.status_code == 200, r.text
axis2_id = r.json()["run_id"]
_wait_until_done(client, axis2_id, timeout=30.0)
res2 = client.get(f"/runs/{axis2_id}/result").json()
text = json.dumps(res2)
ok, offenders = _has_no_symbols(text)
assert ok, f"supervised axis-2 leaked symbols: {offenders}"
assert len(res2["winning"]["full_scores"]) > 0
def test_hnsc_hpv_run_is_airgap_clean():
"""HNSC + HPV (binary AUROC) — runs end-to-end on the synthetic
fixture, payload is opaque-only, no symbol leaks."""
client = TestClient(api_app.app)
run_id = _post_tiny_run(
client,
{"target": "hpv", "metric": "auroc"},
prefilter_n=None,
engine="v2",
dataset="hnsc",
)
_wait_until_done(client, run_id, timeout=30.0)
res = client.get(f"/runs/{run_id}/result").json()
assert res["objective_spec"]["target"] == "hpv"
assert res["objective_spec"]["metric"] in ("auroc", "auroc_omni")
# Opaque-only payload — primary airgap guarantee. We do NOT insist
# opaque IDs are present (tiny GP budgets can land on a no-Select
# Reduce(M, …) winner); same allowance as the unsup tests.
text = json.dumps(res)
ok, offenders = _has_no_symbols(text)
assert ok, f"HNSC/HPV result leaked symbols: {offenders}"
def test_transfer_endpoint_rejects_non_hpv_run():
"""GET /runs/{id}/transfer must reject anything that isn't a
completed HNSC/HPV run — MSI (coadread), TMB, unsup all → 400.
Unknown run → 404."""
client = TestClient(api_app.app)
# Unknown → 404.
r = client.get("/runs/doesnotexist/transfer")
assert r.status_code == 404
# Coadread MSI run → 400.
msi_run = _post_tiny_run(
client, {"target": "msi", "metric": "auroc"}, engine="v2",
)
_wait_until_done(client, msi_run)
r = client.get(f"/runs/{msi_run}/transfer")
assert r.status_code == 400
assert "HNSC/HPV" in r.text or "hnsc" in r.text.lower()
def test_transfer_endpoint_carries_only_winner_revealed_symbols(monkeypatch):
"""The /transfer response's gene NAMES must be exactly the
winner's revealed symbols (found + missing). GSE65858's full gene
list never crosses back into the payload; the sealed map is never
dumped."""
client = TestClient(api_app.app)
# Monkeypatch the validate.transfer_gse65858.transfer_score to a
# stub that returns a known fake payload — so this test doesn't
# need real GSE65858 parquets on disk and stays hermetic.
import validate.transfer_gse65858 as tg
def fake_transfer(symbols, *, n_permutations=1000, seed=0, processed_dir=None):
return {
"auroc": 0.83,
"p": 0.02,
"n": 100,
"n_pos": 40,
"n_neg": 60,
"n_found": len(symbols),
"n_missing": 0,
"found_symbols": list(symbols),
"missing_symbols": [],
}
monkeypatch.setattr(tg, "transfer_score", fake_transfer)
run_id = _post_tiny_run(
client,
{"target": "hpv", "metric": "auroc"},
prefilter_n=None,
engine="v2",
dataset="hnsc",
)
_wait_until_done(client, run_id, timeout=30.0)
# Fetch the result to learn the winner's opaque IDs.
res = client.get(f"/runs/{run_id}/result").json()
winner_ids = list(res["winning"].get("gene_ids") or [])
if not winner_ids:
# Tiny GP runs can land on a no-Select winner; skip the
# tightest check but still exercise the endpoint.
r = client.get(f"/runs/{run_id}/transfer")
assert r.status_code in (200, 400)
return
from airgap.seal import _read_sealed
id_to_sym = _read_sealed().get("id_to_symbol", {})
winner_symbols = [id_to_sym[i] for i in winner_ids if i in id_to_sym]
r = client.get(f"/runs/{run_id}/transfer")
assert r.status_code == 200, r.text
payload = r.json()
assert payload["cohort"] == "GSE65858"
assert payload["platform"].startswith("Illumina")
all_names = list(payload["found_symbols"]) + list(payload["missing_symbols"])
# Every name in the payload is a winner symbol.
for sym in all_names:
assert sym in winner_symbols, (
f"/transfer leaked a non-winner symbol: {sym}"
)
# None of the OTHER symbols in the sealed map leak into the payload.
# Use whole-word matching so a winner like "AARSD1" doesn't false-
# positive on a non-winner symbol "AARS" via substring.
other_symbols = [s for s in id_to_sym.values() if s not in winner_symbols]
text = json.dumps(payload)
for s in other_symbols[:50]:
if re.search(rf"\b{re.escape(s)}\b", text):
raise AssertionError(
f"/transfer leaked non-winner sealed-map symbol: {s}"
)
def test_transfer_endpoint_425_while_running():
client = TestClient(api_app.app)
body = {
"objective_spec": {"target": "hpv", "metric": "auroc"},
"params": {
"generations": 3, "population": 12, "genes_per_set": 4,
"max_sets": 2, "lambda": 0.005, "seed": 0,
"prefilter_n": 20, "permutations": 5,
},
"engine": "v2",
"dataset": "hnsc",
}
r = client.post("/runs", json=body)
run_id = r.json()["run_id"]
r2 = client.get(f"/runs/{run_id}/transfer")
# Either mid-run (425) or already-done (200/503 if fixture cohort
# missing) — never a 500.
assert r2.status_code in (200, 425, 503, 400)
def test_hnsc_rejects_msi_target():
"""The (dataset, target) validation must reject mismatched combos."""
client = TestClient(api_app.app)
r = client.post("/runs", json={
"objective_spec": {"target": "msi", "metric": "auroc"},
"params": {
"generations": 3, "population": 12, "genes_per_set": 4,
"max_sets": 2, "lambda": 0.005, "seed": 0,
"prefilter_n": None, "permutations": 5,
},
"engine": "v2",
"dataset": "hnsc",
})
assert r.status_code == 400
assert "hnsc" in r.text.lower()
def test_coadread_rejects_hpv_target():
client = TestClient(api_app.app)
r = client.post("/runs", json={
"objective_spec": {"target": "hpv", "metric": "auroc"},
"params": {
"generations": 3, "population": 12, "genes_per_set": 4,
"max_sets": 2, "lambda": 0.005, "seed": 0,
"prefilter_n": None, "permutations": 5,
},
"engine": "v2",
"dataset": "coadread",
})
assert r.status_code == 400
def test_evaluate_rejects_hnsc_reference_in_coadread():
"""p16 belongs to the HNSC dataset; asking for it under coadread
must 400, and vice versa for MMR under hnsc."""
client = TestClient(api_app.app)
r = client.post("/evaluate", json={
"gene_ids": ["g00001"],
"reference_set": "p16",
"dataset": "coadread",
})
assert r.status_code == 400
r = client.post("/evaluate", json={
"gene_ids": ["g00001"],
"reference_set": "MMR",
"dataset": "hnsc",
})
assert r.status_code == 400
def test_modules_endpoint_returns_opaque_only_modules():
"""A coherence-on v2 run exposes a /runs/{id}/modules ranking that
is sorted by combined held-out AUROC, opaque-only on the wire, and
surfaces the run's exact train/test split sizes."""
client = TestClient(api_app.app)
body = {
"objective_spec": {"target": "msi", "metric": "auroc"},
"params": {
"generations": 3, "population": 12, "genes_per_set": 4,
"max_sets": 2, "lambda": 0.005, "seed": 0,
"prefilter_n": 20, "permutations": 5,
},
"engine": "v2",
"dataset": "coadread",
"coherence": True,
}
r = client.post("/runs", json=body)
assert r.status_code == 200, r.text
run_id = r.json()["run_id"]
_wait_until_done(client, run_id, timeout=30.0)
# Run status now exposes the coherence flag.
summary = client.get(f"/runs/{run_id}").json()
assert summary["coherence"] is True
assert summary["dataset"] == "coadread"
mods = client.get(f"/runs/{run_id}/modules")
assert mods.status_code == 200, mods.text
payload = mods.json()
assert payload["run_id"] == run_id
assert payload["coherence"] is True
assert payload["metric_kind"] == "auroc"
assert payload["n_train"] + payload["n_test"] > 0
assert payload["n_modules"] >= 1
# Modules carry only opaque IDs; no symbol leak anywhere.
text = json.dumps(payload)
ok, offenders = _has_no_symbols(text)
assert ok, f"/runs/{run_id}/modules leaked symbols: {offenders}"
# Each module: ≥2 genes, every per_gene row has an opaque ID, the
# combined AUROC is a finite float (or None — never NaN literal in
# JSON), and the modules are sorted by combined AUROC desc.
aurocs: list[float] = []
for m in payload["modules"]:
assert isinstance(m["gene_ids"], list) and len(m["gene_ids"]) >= 2
assert m["size"] == len(m["gene_ids"])
for g in m["gene_ids"]:
assert _OPAQUE_RE.match(g), g
for pg in m["per_gene"]:
assert _OPAQUE_RE.match(pg["id"]), pg["id"]
# ref_sets must be present (possibly empty); the values are
# reference-set NAMES (e.g. "MMR", "p16") — never gene symbols.
assert isinstance(m["ref_sets"], list)
for name in m["ref_sets"]:
assert isinstance(name, str)
combined = m["combined_holdout"]
if combined is not None:
aurocs.append(float(combined))
# Sorted descending.
assert aurocs == sorted(aurocs, reverse=True), aurocs
def test_modules_endpoint_rejects_unsupervised_run():
"""Unsupervised has no target to evaluate against; /modules must 400."""
client = TestClient(api_app.app)
run_id = _post_tiny_run(
client,
{"target": "none", "metric": "structure"},
prefilter_n=None,
engine="v2",
)
_wait_until_done(client, run_id, timeout=30.0)
r = client.get(f"/runs/{run_id}/modules")
assert r.status_code == 400
assert "supervised" in r.text.lower()
def test_modules_endpoint_returns_425_while_running():
client = TestClient(api_app.app)
# Don't wait — query /modules before the run is done. The
# synthetic GP usually finishes in milliseconds, so we accept
# either 425 (still running) or 200 (already done). Both prove
# the endpoint never crashes; we just need to confirm it doesn't
# 500 mid-run.
body = {
"objective_spec": {"target": "msi", "metric": "auroc"},
"params": {
"generations": 3, "population": 12, "genes_per_set": 4,
"max_sets": 2, "lambda": 0.005, "seed": 0,
"prefilter_n": 20, "permutations": 5,
},
"engine": "v2",
"coherence": True,
}
r = client.post("/runs", json=body)
run_id = r.json()["run_id"]
r2 = client.get(f"/runs/{run_id}/modules")
assert r2.status_code in (200, 425)
def test_operator_usage_endpoint_returns_opaque_counts():
"""A v2 run exposes /runs/{id}/operator-usage with per-operator
counts across every candidate in every persisted generation —
operator keywords + integer counts only, no gene IDs or symbols."""
client = TestClient(api_app.app)
run_id = _post_tiny_run(
client, {"target": "msi", "metric": "auroc"}, engine="v2",
)
_wait_until_done(client, run_id)
r = client.get(f"/runs/{run_id}/operator-usage")
assert r.status_code == 200, r.text
payload = r.json()
assert payload["run_id"] == run_id
assert payload["n_generations"] >= 1
assert payload["n_candidates"] >= 1
# All 8 grammar operators present, in the documented order.
names = [op["name"] for op in payload["operators"]]
assert names == [
"Select", "Reduce", "Combine", "Split",
"Associate", "Effect", "Fit/Apply", "Search",
]
# Counts are non-negative ints; programs_using ≤ n_candidates.
for op in payload["operators"]:
assert isinstance(op["total_uses"], int) and op["total_uses"] >= 0
assert (
isinstance(op["programs_using"], int)
and 0 <= op["programs_using"] <= payload["n_candidates"]
)
# Reduce + Select are the engine_v2 grammar's required leaves —
# the v2 depth-floor guarantees every Vector closes through
# Reduce(Select(M, …), agg), so both must appear in every run.
by_name = {op["name"]: op for op in payload["operators"]}
assert by_name["Select"]["total_uses"] >= 1
assert by_name["Reduce"]["total_uses"] >= 1
# Search defaults ON now (DEFAULT_RATES["search"] = 0.05); it may
# or may not have fired on a tiny synthetic 3×12 run, so the only
# invariant is non-negative counts.
assert by_name["Search"]["total_uses"] >= 0
# Airgap: no gene symbol leaks anywhere in the payload.
text = json.dumps(payload)
ok, offenders = _has_no_symbols(text)
assert ok, f"/runs/{run_id}/operator-usage leaked symbols: {offenders}"
def test_rates_override_search_zero_pins_search_to_zero():
"""A run with rates_override={search:0.0} replaces the old
enable_search=False toggle: the operator-usage endpoint must
report zero Search uses across the entire population, and
/runs/{id} must surface the override for the UI."""
client = TestClient(api_app.app)
body = {
"objective_spec": {"target": "msi", "metric": "auroc"},
"params": {
"generations": 3, "population": 12, "genes_per_set": 4,
"max_sets": 2, "lambda": 0.005, "seed": 0,
"prefilter_n": 20, "permutations": 5,
},
"engine": "v2",
"rates_override": {"search": 0.0},
}
r = client.post("/runs", json=body)
assert r.status_code == 200, r.text
run_id = r.json()["run_id"]
_wait_until_done(client, run_id, timeout=30.0)
usage = client.get(f"/runs/{run_id}/operator-usage").json()
by_name = {op["name"]: op for op in usage["operators"]}
assert by_name["Search"]["total_uses"] == 0
assert by_name["Search"]["programs_using"] == 0
# /runs/{id} surfaces the override for the UI.
summary = client.get(f"/runs/{run_id}").json()
assert summary["rates_override"] == {"search": 0.0}
def test_operator_usage_endpoint_425_while_running():
"""Like /modules: 425 if the run hasn't persisted a result yet."""
client = TestClient(api_app.app)
body = {
"objective_spec": {"target": "msi", "metric": "auroc"},
"params": {
"generations": 3, "population": 12, "genes_per_set": 4,
"max_sets": 2, "lambda": 0.005, "seed": 0,
"prefilter_n": 20, "permutations": 5,
},
"engine": "v2",
}
r = client.post("/runs", json=body)
run_id = r.json()["run_id"]
r2 = client.get(f"/runs/{run_id}/operator-usage")
assert r2.status_code in (200, 425)
def test_operator_usage_endpoint_404_for_unknown_run():
client = TestClient(api_app.app)
r = client.get("/runs/doesnotexist/operator-usage")
assert r.status_code == 404
def test_modules_endpoint_404_for_unknown_run():
client = TestClient(api_app.app)
r = client.get("/runs/doesnotexist/modules")
assert r.status_code == 404
@pytest.mark.parametrize("engine_choice", ["v1", "v2"])
def test_population_endpoint_is_airgap_clean(engine_choice):
client = TestClient(api_app.app)
spec = {"target": "msi", "metric": "auroc"}
run_id = _post_tiny_run(client, spec, engine=engine_choice)
_wait_until_done(client, run_id)
# /runs/{id} status exposes the generations-persisted count.
summary = client.get(f"/runs/{run_id}").json()
assert summary["engine"] == engine_choice
assert summary["generations_persisted"] >= 1
for gen in range(summary["generations_persisted"]):
r = client.get(f"/runs/{run_id}/population/{gen}")
assert r.status_code == 200, r.text
body = r.json()
assert body["generation"] == gen
assert "candidates" in body and len(body["candidates"]) >= 1
text = json.dumps(body)
ok, offenders = _has_no_symbols(text)
assert ok, f"population[{gen}] leaked symbols: {offenders}"
# Every candidate carries program_repr (typed for v2, fixed for v1).
for c in body["candidates"]:
assert "program_repr" in c and "fitness" in c
# Asking for a generation that doesn't exist must 404 (not 500).
r = client.get(f"/runs/{run_id}/population/9999")
assert r.status_code == 404