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
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: help demo test lint ci clean bench-leakage bench-baseline bench-hidden-active bench-cluster-split bench-expert-ablation bench-selectivity bench-feature-decomp bench-gate generate phase3 pilot validate-scoring validate-scoring-phase3 validate-scoring-strict external-predict pilot-confident presynth-qc gold-standard diversity synthesis-order novelty-broad external-consensus questionnaire gate-check ip-report benchmark-card wave0-5-gate-check wave0-5-novelty-audit wave0-5-novelty-audit-v2 wave0-5-panel wave0-5-evidence wave0-5-fill-external wave0-5b-generate wave0-5b-filter
.PHONY: help demo test lint ci clean bench-leakage bench-baseline bench-hidden-active bench-cluster-split bench-expert-ablation bench-selectivity bench-feature-decomp bench-gate regenerate-all generate phase3 pilot validate-scoring validate-scoring-phase3 validate-scoring-strict external-predict pilot-confident presynth-qc gold-standard diversity synthesis-order novelty-broad external-consensus questionnaire gate-check ip-report benchmark-card wave0-5-gate-check wave0-5-novelty-audit wave0-5-novelty-audit-v2 wave0-5-panel wave0-5-evidence wave0-5-fill-external wave0-5b-generate wave0-5b-filter

PYTHON := $(shell [ -f .venv/bin/python ] && echo .venv/bin/python || echo python3)
PYTEST := $(shell [ -f .venv/bin/pytest ] && echo .venv/bin/pytest || echo pytest)
Expand Down Expand Up @@ -33,6 +33,7 @@ help:
@echo " make bench-selectivity Within-AMP selectivity (hemolytic vs selective)"
@echo " make bench-feature-decomp Per-feature selective_vs_hemolytic decomposition"
@echo " make bench-gate Benchmark regression gate (AUROC drift check)"
@echo " make regenerate-all Run all pipeline + benchmarks and verify determinism"
@echo " make bench-hidden-active Hidden-positive recovery on mixed benchmark set"
@echo ""
@echo " make wave0-5-gate-check Run Wave 0.5 gates W0.5-1 through W0.5-7"
Expand Down Expand Up @@ -320,6 +321,9 @@ wave0-5-panel: wave0-5-fill-external
wave0-5-evidence:
PYTHONPATH=src $(PYTHON) scripts/generate_wave0_5_evidence_certs.py

regenerate-all:
PYTHONPATH=src $(PYTHON) scripts/regenerate_all.py

metrics-snapshot:
PYTHONPATH=src $(PYTHON) -m openamp_foundry.cli bench metrics-snapshot \
--out outputs/metrics_snapshot.json
Expand Down
25 changes: 12 additions & 13 deletions docs/50_LOOP_PLAN.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# 50-Loop Execution Plan

> **Status:** Strategic roadmap. Updated v0.5.23.
> **Current state:** 1669 tests, pipeline AUROC 0.7832, calibration intake + gate shipped,
> calibration end-to-end smoke test shipped (Loop 4), Wave 0.5 panel ready
> (24 candidates, 15 families), no wet-lab data yet.
> **Status:** Strategic roadmap. Updated v0.5.26.
> **Current state:** 1700 tests, pipeline AUROC 0.7832, calibration intake + gate shipped,
> calibration e2e test (Loop 4), benchmark regression gate (Loop 5), deterministic regenerate-all (Loop 6),
> public API exports (Loop 7), Wave 0.5 panel ready (24 candidates, 15 families), no wet-lab data yet.
>
> Each loop = one focused PR: bottleneck identified → implemented → verified → merged.
> Loops compound. Earlier loops unlock later ones.
Expand All @@ -21,7 +21,7 @@ Make the repo self-documenting, clean to navigate, and easy for a fresh agent or
| 3 | `docs/` has 37+ files; some may be stale or duplicate. AGENTS.md section numbers may drift from actual module versions | Doc audit: verify every doc's "last updated" matches reality; flag/archive stale content; deduplicate overlapping docs (e.g. SAFE_SCOPE.md vs ARCHITECTURE.md threat model) | test_docs_consistent.py passes; no stale benchmark numbers |
| 4 | No end-to-end smoke test for the full calibration chain (panel→intake→gate→verdict) | `tests/test_calibration_e2e.py`: exercises the full flow on synthetic data, asserts exit codes and output structure | 3+ new tests, full calibration flow verified |
| 5 | No CI job that runs `make demo` + `make validate-scoring` + `make bench-triage` on every PR | `.github/workflows/ci.yml` expanded with benchmark gate workflow. PRs that regress AUROC >0.02 are flagged | CI passes, benchmark gate enforced |
| 6 | No script to regenerate all derived outputs (metrics_snapshot, evidence certs, etc.) deterministically | `make regenerate-all` target that runs full pipeline, benchmarks, and evidence generation from versioned inputs | Deterministic: `git diff --stat` shows zero unexpected changes |
| 6 ✅ | No script to regenerate all derived outputs deterministically | `scripts/regenerate_all.py` + `make regenerate-all` + 11 tests | All 11 targets pass, pipeline deterministic |
| 7 | Some `__init__.py` files in subpackages (benchmark, scoring, selection) may be empty or incomplete | Audit and populate all subpackage `__init__.py` exports; add `__all__` everywhere | `from openamp_foundry.benchmark import ...` works cleanly (v0.5.25) |
| 8 | No contributor onboarding guide that specifically calls out safety review and claim policy | `CONTRIBUTING.md` updated with safety-first contribution checklist; PR template added | New contributor can open a safe first PR in <30 min |

Expand Down Expand Up @@ -171,7 +171,7 @@ If no data arrives, virtual assay scaffolding continues independently.
## Current Position

```
Phase 0: Loop 7 of 8 (next loop)
Phase 0: Loop 8 of 8 (next loop)
Phase 1: Not started
Phase 2: Not started
Phase 3: Not started
Expand All @@ -187,19 +187,18 @@ Phase 4: Not started
| 3 ✅ | Doc audit — verify freshness, deduplicate, archive stale content | Performed as part of Loop 1/2 structure work; calibration docs up to date | Docs consistent |
| 4 ✅ | No end-to-end smoke test for the full calibration chain (panel→intake→gate→verdict) | `tests/test_calibration_e2e.py`: 14 new tests exercising Python API, CLI, and Makefile targets for both passing and failing paths | 1669 pass, full calibration flow verified |
| 5 ✅ | No CI benchmark regression gate — AUROC can silently degrade | `scripts/benchmark_gate.py`, `tests/test_benchmark_gate.py` (13 tests), `make bench-gate` target, CI step that fails on AUROC drop >0.02 | 1682 pass, `make bench-gate` exits 0, CI gate committed |
| 7 ✅ | 11 subpackages had empty `__init__.py`; only calibration/cli/simulation exposed a curated `__all__` | Populated every subpackage `__init__.py` with re-exports + `__all__`. Fixed a latent circular import in `features.physchem` by deferring the boman import to function scope. Added `tests/test_public_api_imports.py` (7 tests) | 1682 pass, lint clean on new files; every documented public name now reachable from package root |
| 6 ✅ | No script to regenerate all derived outputs deterministically | `scripts/regenerate_all.py`, `tests/test_regenerate_all.py` (11 tests), `make regenerate-all` target | 1700 pass, `make regenerate-all` exits 0, pipeline deterministic on all 11 targets |
| 7 ✅ | 11 subpackages had empty `__init__.py`; only calibration/cli/simulation exposed a curated `__all__` | Populated every subpackage `__init__.py` with re-exports + `__all__`. Fixed a latent circular import in `features.physchem` by deferring the boman import to function scope. Added `tests/test_public_api_imports.py` (7 tests) | 1700 pass, lint clean on new files; every documented public name now reachable from package root |

### Remaining (Phase 0)

| Loop | Bottleneck | Deliverable | Verification |
|------|-----------|-------------|-------------|
| 6 | No script to regenerate all derived outputs (metrics_snapshot, evidence certs, etc.) deterministically | `make regenerate-all` target that runs full pipeline, benchmarks, and evidence generation from versioned inputs | Deterministic: `git diff --stat` shows zero unexpected changes |
| 7 | Some `__init__.py` files in subpackages (benchmark, scoring, selection) may be empty or incomplete | Audit and populate all subpackage `__init__.py` exports; add `__all__` everywhere | `from openamp_foundry.benchmark import ...` works cleanly (v0.5.25) |
| 8 | No contributor onboarding guide that specifically calls out safety review and claim policy | `CONTRIBUTING.md` updated with safety-first contribution checklist; PR template added | New contributor can open a safe first PR in <30 min |

### Phase 0 exit criteria:
- `from openamp_foundry.calibration import GateVerdict` works
- `make ci` passes with benchmark gate
- A new agent can read README → run demo → understand calibration flow → contribute safely in one session
- ✅ `from openamp_foundry.calibration import GateVerdict` works
- ✅ `make ci` passes with benchmark gate
- ❌ A new agent can read README → run demo → understand calibration flow → contribute safely in one session (Loop 8)

**Next loop:** Loop 6 — deterministic regenerate-all script.
**Next loop:** Loop 8 — CONTRIBUTING.md and PR template for safe contributions.
120 changes: 120 additions & 0 deletions scripts/regenerate_all.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Regenerate all derived outputs and verify determinism.

Usage:
python scripts/regenerate_all.py # run + check
python scripts/regenerate_all.py --update # run + update baseline
"""

from __future__ import annotations

import argparse
import hashlib
import subprocess
import sys
from pathlib import Path


TRACKED: list[str] = [
"outputs/metrics_snapshot.json",
]

TARGETS: list[str] = [
"demo",
"validate-scoring",
"validate-scoring-phase3",
"validate-scoring-strict",
"bench-leakage",
"bench-cluster-split",
"bench-expert-ablation",
"bench-selectivity",
"bench-triage",
"bench-feature-decomp",
"metrics-snapshot",
]


def sha256(path: str | Path) -> str:
return hashlib.sha256(Path(path).read_bytes()).hexdigest()


def run_make(target: str) -> int:
env = {**subprocess.os.environ, "PYTHONPATH": "src"}
result = subprocess.run(
["make", target],
capture_output=True, text=True,
env=env,
)
if result.returncode != 0:
print(f" FAIL: make {target} (exit {result.returncode})")
short = (result.stderr or "")[:600]
if short:
for line in short.splitlines():
print(f" {line}")
else:
print(f" OK: make {target}")
return result.returncode


def run_targets(targets: list[str] | None = None) -> int:
failures = 0
for t in targets or TARGETS:
rc = run_make(t)
if rc != 0:
failures += 1
print(f" -> stopping early after make {t} failed")
return 1
return 0


def check_determinism(
tracked: list[str] | None = None,
) -> int:
result = subprocess.run(
["git", "diff", "--exit-code", "--stat"] + (tracked or TRACKED),
capture_output=True, text=True,
)
if result.returncode == 0:
print("\nPASS: All tracked outputs unchanged — pipeline is deterministic")
return 0
print("\nFAIL: Tracked outputs changed:")
print(result.stdout[:1000])
print("Run without --update, commit the new baseline, and re-run.")
return 1


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Regenerate all derived outputs and verify determinism."
)
parser.add_argument(
"--update",
action="store_true",
help="Update baseline (git add tracked outputs) after regeneration",
)
parser.add_argument(
"--skip-targets",
action="store_true",
help="Skip running targets (only check determinism)",
)
args = parser.parse_args(argv)

if not args.skip_targets:
print("=== Regenerating all derived outputs ===")
rc = run_targets()
if rc != 0:
return rc

rc = check_determinism()
if rc == 0:
return 0

if args.update:
print("\nUpdating baseline...")
subprocess.run(["git", "add"] + TRACKED, check=True)
print("Done. Run `git commit` to save the updated baseline.")
return rc


if __name__ == "__main__":
sys.exit(main())
140 changes: 140 additions & 0 deletions tests/test_regenerate_all.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path

from scripts.regenerate_all import check_determinism, run_make, run_targets, sha256


class TestSha256:
def test_returns_consistent_hash(self, tmp_path):
f = tmp_path / "a.txt"
f.write_text("hello")
h1 = sha256(f)
h2 = sha256(f)
assert h1 == h2

def test_changes_when_content_changes(self, tmp_path):
f = tmp_path / "a.txt"
f.write_text("hello")
h1 = sha256(f)
f.write_text("world")
h2 = sha256(f)
assert h1 != h2


class TestRunTargets:
def test_returns_0_for_help_target(self):
rc = run_targets(["help"])
assert rc == 0

def test_returns_1_for_bad_target(self):
rc = run_targets(["nonexistent-target-xyz"])
assert rc == 1


class TestRunMake:
def test_returns_0_for_help(self):
assert run_make("help") == 0

def test_returns_nonzero_for_bad_target(self):
assert run_make("nonexistent-xyz") != 0


class TestCheckDeterminism:
def test_passes_when_no_diff(self, tmp_path, monkeypatch):
tracked = [str(tmp_path / "stable.json")]
Path(tracked[0]).write_text("{}")

subprocess_run_calls = []

def fake_run(cmd, **kw):
subprocess_run_calls.append(cmd)
class Fake:
returncode = 0
stdout = ""
stderr = ""
return Fake()

monkeypatch.setattr(subprocess, "run", fake_run)
rc = check_determinism(tracked=tracked)
assert rc == 0

def test_fails_when_diff_detected(self, tmp_path, monkeypatch):
tracked = [str(tmp_path / "changed.json")]
Path(tracked[0]).write_text("{}")

subprocess_run_calls = []

def fake_run(cmd, **kw):
subprocess_run_calls.append(cmd)
class Fake:
returncode = 1
stdout = " outputs/metrics_snapshot.json | 2 +-\n"
stderr = ""
return Fake()

monkeypatch.setattr(subprocess, "run", fake_run)
rc = check_determinism(tracked=tracked)
assert rc == 1


class TestRegenerateAllScript:
def test_help_flag_succeeds(self):
result = subprocess.run(
[sys.executable, "scripts/regenerate_all.py", "--help"],
capture_output=True, text=True,
cwd=Path(__file__).resolve().parent.parent,
)
assert result.returncode == 0
assert "Regenerate all derived outputs" in result.stdout

def test_skip_targets_passes_when_no_diff(self, tmp_path, monkeypatch):
snap = tmp_path / "metrics_snapshot.json"
snap.write_text(json.dumps({"standard": {"auroc": 0.7832}}), encoding="utf-8")

monkeypatch.setattr(
"scripts.regenerate_all.TRACKED",
[str(snap)],
)

def fake_run(cmd, **kw):
class Fake:
returncode = 0
stdout = ""
stderr = ""
return Fake()

monkeypatch.setattr(subprocess, "run", fake_run)

from scripts.regenerate_all import main
rc = main(["--skip-targets"])
assert rc == 0

def test_skip_targets_fails_on_diff(self, tmp_path, monkeypatch):
snap = tmp_path / "metrics_snapshot.json"
snap.write_text(json.dumps({"standard": {"auroc": 0.7832}}), encoding="utf-8")

monkeypatch.setattr(
"scripts.regenerate_all.TRACKED",
[str(snap)],
)

call_count = 0

def fake_run(cmd, **kw):
nonlocal call_count
call_count += 1
class Fake:
returncode = 0 if call_count > 1 else 1
stdout = " outputs/metrics_snapshot.json | 2 +-\n"
stderr = ""
return Fake()

monkeypatch.setattr(subprocess, "run", fake_run)

from scripts.regenerate_all import main
rc = main(["--skip-targets"])
assert rc == 1
Loading