From 048a7dd172e3124e40b59c78d45d1ff152c340d8 Mon Sep 17 00:00:00 2001 From: OpenCode Date: Mon, 6 Jul 2026 16:20:54 +0700 Subject: [PATCH] =?UTF-8?q?v0.5.62=20=E2=80=94=20Pre-Registered=20Pass/Fai?= =?UTF-8?q?l=20Criteria=20(Loop=2041)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Machine-readable pass/fail gates for Wave 1 wet-lab batch. Changes: - configs/wave1_pass_fail.yaml -- YAML config: primary endpoint (MIC <= 32, >= 3 candidates), secondary (MIC <= 32 and TI > 10, >= 1), confirmed hit definition, hemolysis safety (0 toxic), positive control range (SEED-001_VAR_064, MIC 4-32), batch requirements (>= 20 tested). - scripts/check_wave1_pass_fail.py -- CLI validation: exit 0/3/2. - tests/test_wave1_pass_fail.py -- 11 tests. - docs/50_LOOP_PLAN.md -- Loop 41 complete, next Loop 42. - docs/ROADMAP.md -- v0.5.62 entry. - docs/METRICS_CURRENT.md -- Pipeline v0.5.62, 1977 tests. 1977 tests passing. --- configs/wave1_pass_fail.yaml | 69 ++++++++++ docs/50_LOOP_PLAN.md | 4 +- docs/METRICS_CURRENT.md | 5 +- docs/ROADMAP.md | 21 +++ scripts/check_wave1_pass_fail.py | 207 ++++++++++++++++++++++++++++++ tests/test_wave1_pass_fail.py | 213 +++++++++++++++++++++++++++++++ 6 files changed, 515 insertions(+), 4 deletions(-) create mode 100644 configs/wave1_pass_fail.yaml create mode 100644 scripts/check_wave1_pass_fail.py create mode 100644 tests/test_wave1_pass_fail.py diff --git a/configs/wave1_pass_fail.yaml b/configs/wave1_pass_fail.yaml new file mode 100644 index 00000000..a81346ba --- /dev/null +++ b/configs/wave1_pass_fail.yaml @@ -0,0 +1,69 @@ +# Wave 1 Wet-Lab Pass/Fail Criteria +# +# Pre-registered before any wet-lab data is seen. +# Machine-readable gates checked by check_wave1_pass_fail.py. +# Locked: do not modify without a signed deviation record. + +pipeline_version: "0.5.x" +locked_at: "2026-07-06" +locked_by: "OpenAMP agent (pre-registration template)" + +# Primary endpoint: MIC ≤ 32 ug/mL against E. coli ATCC 25922 +primary_endpoint: + organism: "Escherichia coli ATCC 25922" + assay_type: MIC + max_mic_ugml: 32 + min_passing_candidates: 3 + +# Secondary endpoint: MIC ≤ 32 ug/mL and TI > 10 (selective hit) +secondary_endpoint: + max_mic_ugml: 32 + min_therapeutic_index: 10 + min_passing_candidates: 1 + +# Confirmed hit definition +confirmed_hit: + mic_ugml_max: 32 + ti_min: 10 + positive_control_required: true + min_confirmed_hits: 1 + +# Hemolysis safety gate +hemolysis: + assay_type: hemolysis_RBC + # Therapeutic index = HC50 / MIC. TI > 10 = selective. + # TI < 2 = toxic (exclude from further consideration). + ti_toxic_threshold: 2 + ti_selective_threshold: 10 + # No candidate with TI < 2 is acceptable in a passing batch + max_toxic_candidates: 0 + +# Serum stability (optional, informational) +serum_stability: + assay_type: serum_stability + pass_criterion: "> 50% intact at 60 min" + # Informational only — does not gate the batch + +# Positive control requirements +positive_controls: + - candidate_id: "SEED-001_VAR_064" + expected_mic_min_ugml: 4 + expected_mic_max_ugml: 32 + description: "Magainin-1 derivative — validates assay is working" + +# Batch-level acceptance +batch_level: + # Batch is valid only if all these pass + positive_control_passed: true + negative_control_passed: true + min_total_candidates_tested: 20 + # No data points excluded without documented justification + require_all_results_reported: true + +# Negative result reporting +negative_result_archive: + # All tested candidates must have entries, even if inactive + # Archive to outputs/negative_result_archive.csv via calibration-intake + require_inactive_reported: true + require_toxic_reported: true + require_control_failures_reported: true diff --git a/docs/50_LOOP_PLAN.md b/docs/50_LOOP_PLAN.md index 4bff65df..b9e968ba 100644 --- a/docs/50_LOOP_PLAN.md +++ b/docs/50_LOOP_PLAN.md @@ -125,7 +125,7 @@ Prepare for real wet-lab partners. Make the pack reviewable, the evidence tracea | Loop | Bottleneck | Deliverable | Verification | |------|-----------|-------------|-------------| | 40 ✅ | No assay-partner onboarding pack. A new CRO gets no README for how to run the panel | `docs/LAB_PARTNER_ONBOARDING.md`: panel manifest, synthesis instructions, assay protocol references, data return schema, positive/negative control sequences, safety notes, timeline, controls. v0.5.61 | A new CRO can read this and know exactly what to do | -| 41 | No pre-registered pass/fail criteria for the Wave 1 wet-lab batch. ASSAY_PREREGISTRATION exists but criteria are not machine-checkable | `configs/wave1_pass_fail.yaml`: machine-readable pass/fail gates for Wave 1 (MIC ≤ 32 for ≥ N candidates, hemolysis ≤ 10% for ≥ M candidates, at least 1 control passes) | CI validates criteria against lab result schema | +| 41 ✅ | No pre-registered pass/fail criteria for the Wave 1 wet-lab batch. ASSAY_PREREGISTRATION exists but criteria are not machine-checkable | `configs/wave1_pass_fail.yaml`: machine-readable pass/fail gates (MIC ≤ 32 for ≥ 3 candidates, TI > 10 for ≥ 1, positive control within range, 0 toxic, ≥ 20 tested). `scripts/check_wave1_pass_fail.py`: validation CLI, exit 0/3/2. v0.5.62 | 11 tests, 1977 total. CLI validates synthetic results against YAML criteria | | 42 | No expert-review GitHub template. Review process is ad-hoc | `.github/ISSUE_TEMPLATE/expert_review.yml`: structured form for expert reviewers covering novelty, safety, mechanism plausibility, experiment design | Template generates a review issue with all required fields | | 43 | No decision-log format for human review. AGENTS.md requires human review for 6 decision types but no machine-readable log format exists | `schemas/decision_log.schema.json`: JSON Schema for human review decisions. Fields: date, reviewer, decision_type, evidence_refs, reasoning_notes, dissent_flag | Schema validates against real decision examples | | 44 | No candidate batch pack that a lab partner can download as a single artifact | `scripts/build_lab_batch_pack.py`: generates a zip with candidate CSV, evidence certificates, synthesis order, assay protocol refs, controls manifest, and data return template | `make lab-batch-pack` produces a single zip with all required files | @@ -241,7 +241,7 @@ Phase 4: In progress — Loop 40 ✅ (Loops 40–49) | 28 ✅ | Policy version bump workflow for when real data arrives | `scripts/bump_recalibration_policy.py`: standalone script with `--dry-run`, decision-log guard, auto-increment + write. CI guard in `ci.yml` validates policy version changes against base branch. v0.5.49. 9 tests. | CI rejects policy PRs without valid decision log; `make bump-policy-version` and `make bump-policy-version-dry-run` available | | 29 ✅ | No public negative-result archive format. If Wave 1 yields all negatives, where do they go? | `docs/NEGATIVE_RESULT_ARCHIVE.md`: full template with entry schema, procedures, automation notes, and limitations. Covers pre-selection rejects, selected-untested, lab inactives, lab toxic, control failures. v0.5.50. | Template complete enough for a lab partner to fill; schema defines 18 fields with required/conditional markers | -**Next loop:** Loop 41 — pre-registered pass/fail criteria (configs/wave1_pass_fail.yaml). +**Next loop:** Loop 42 — expert-review GitHub template. **Phase 2 exit criteria (all 5 met ✅):** - ✅ `make calibration-loop` runs from clean checkout, produces batch-2 manifest diff --git a/docs/METRICS_CURRENT.md b/docs/METRICS_CURRENT.md index 664ede7f..1d8a2736 100644 --- a/docs/METRICS_CURRENT.md +++ b/docs/METRICS_CURRENT.md @@ -5,7 +5,8 @@ Machine-readable snapshot: `outputs/metrics_snapshot.json` regenerated with `mak > **Purpose:** One authoritative table of current pipeline metrics. If any doc disagrees > with this file, this file wins. Updated whenever benchmark/benchmark config changes. > -> **Last updated:** 2026-07-06 (lab partner onboarding — v0.5.61) +> **Last updated:** 2026-07-06 (pre-registered pass/fail criteria — v0.5.62) +> **New in v0.5.62:** `configs/wave1_pass_fail.yaml` — machine-readable pass/fail gates (MIC ≤ 32, TI > 10, positive control, 0 toxic, ≥20 tested). `scripts/check_wave1_pass_fail.py` — validation CLI. 11 tests. 1977 total. > **New in v0.5.61:** `docs/LAB_PARTNER_ONBOARDING.md` — CRO onboarding pack with panel summary, synthesis instructions, assay protocols, data return format, controls, safety, timeline. No code changes. 1966 tests. > **New in v0.5.60:** `docs/SIMULATION_BENCHMARK.md` consolidates simulation ablation, cheap-baseline comparison, and weighted-mode gate results. Current conclusion: simulation does not improve ranking; `weighted` remains blocked. > **New in v0.5.59:** `ExternalSimulationAdapter` protocol — wraps third-party callables into `VirtualAssayProxy`. Availability check, graceful error handling, metadata override. ARCHITECTURE.md docs updated. 12 tests. 1965 total. @@ -1116,7 +1117,7 @@ Decoys score low on activity. Selective AMPs score moderately on both. | Metric | Value | |--------|-------| -| Total tests | 1965 | +| Total tests | 1977 | | Coverage (branch) | 99% (6 CLI guard lines only) | | Source modules at 100% | All pipeline, QC, scoring modules | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7900a0d6..79b3aae3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,5 +1,26 @@ # Roadmap +## v0.5.62 — Pre-Registered Pass/Fail Criteria (Loop 41) ✓ (2026-07-06) + +Machine-readable pass/fail gates for the Wave 1 wet-lab batch. Pre-registered +before any wet-lab data is seen. CI-validatable against lab result schema. + +Changes: +- ``configs/wave1_pass_fail.yaml`` — YAML config with: primary endpoint + (MIC ≤ 32 for ≥ 3 candidates against E. coli), secondary endpoint + (MIC ≤ 32 and TI > 10 for ≥ 1), confirmed hit definition, hemolysis + safety gate (0 toxic with TI < 2), positive control range + (SEED-001_VAR_064, MIC 4–32), batch-level requirements (≥ 20 tested, + all controls passed), negative result archive requirements. +- ``scripts/check_wave1_pass_fail.py`` — CLI validation tool: reads lab + result JSONs + criteria YAML, reports per-check pass/fail. Exit 0 if + all pass, 3 if any fail, 2 on missing files. +- ``tests/test_wave1_pass_fail.py`` — 11 tests covering pass, fail, CLI + exit codes, output writing, missing files. +- ``docs/50_LOOP_PLAN.md`` — Loop 41 ✅. Next: Loop 42. +- ``docs/METRICS_CURRENT.md`` — Changelog updated. Test count: 1977. +- 1977 tests passing (1966 existing + 11 new). + ## v0.5.61 — Lab Partner Onboarding Pack (Loop 40) ✓ (2026-07-06) Phase 4 begins with a comprehensive onboarding document for CRO or academic diff --git a/scripts/check_wave1_pass_fail.py b/scripts/check_wave1_pass_fail.py new file mode 100644 index 00000000..ce21fe13 --- /dev/null +++ b/scripts/check_wave1_pass_fail.py @@ -0,0 +1,207 @@ +"""Check Wave 1 lab results against pre-registered pass/fail criteria. + +Reads lab result JSON files from a directory, compares against the +pre-registered criteria in configs/wave1_pass_fail.yaml, and reports +which criteria pass or fail. + +Exit codes: + 0: All criteria pass + 3: One or more criteria fail + 2: Missing files or parse error +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +import yaml + + +def _load_lab_results(results_dir: str) -> list[dict[str, Any]]: + results = [] + p = Path(results_dir) + if not p.exists(): + raise FileNotFoundError(f"Results directory not found: {results_dir}") + for f in sorted(p.iterdir()): + if f.suffix.lower() in (".json",): + data = json.loads(f.read_text(encoding="utf-8")) + results.append(data) + return results + + +def _check_criteria( + results: list[dict[str, Any]], + criteria: dict[str, Any], +) -> dict[str, Any]: + checks: dict[str, Any] = {} + all_pass = True + + # Primary endpoint: MIC <= 32 for >= N candidates + pri = criteria.get("primary_endpoint", {}) + max_mic = pri.get("max_mic_ugml", 32) + min_pass = pri.get("min_passing_candidates", 3) + organism = pri.get("organism", "") + + passing_mic = sum( + 1 for r in results + if r.get("assay_type") == "MIC" + and r.get("result_value", 999) <= max_mic + and (not organism or organism in r.get("organism_or_cell_line", "")) + ) + mic_pass = passing_mic >= min_pass + checks["primary_endpoint_mic"] = { + "pass": mic_pass, + "detail": f"{passing_mic}/{min_pass} candidates with MIC <= {max_mic} ug/mL", + } + if not mic_pass: + all_pass = False + + # Positive control check + pos_controls = criteria.get("positive_controls", []) + pos_pass = True + pos_details = [] + for pc in pos_controls: + cid = pc.get("candidate_id", "") + expected_min = pc.get("expected_mic_min_ugml", 0) + expected_max = pc.get("expected_mic_max_ugml", 999) + matches = [ + r for r in results + if r.get("candidate_id") == cid and r.get("assay_type") == "MIC" + ] + if not matches: + pos_pass = False + pos_details.append(f"{cid}: no results found") + else: + for m in matches: + val = m.get("result_value", -1) + if val < expected_min or val > expected_max: + pos_pass = False + pos_details.append(f"{cid}: MIC={val} outside expected [{expected_min}, {expected_max}]") + else: + pos_details.append(f"{cid}: MIC={val} within expected range") + checks["positive_controls"] = { + "pass": pos_pass, + "detail": "; ".join(pos_details) if pos_details else "No positive controls defined", + } + if not pos_pass: + all_pass = False + + # Hemolysis safety: no toxic candidates + hemo = criteria.get("hemolysis", {}) + toxic_threshold = hemo.get("ti_toxic_threshold", 2) + max_toxic = hemo.get("max_toxic_candidates", 0) + toxic_count = 0 + hemo_details = [] + + for r in results: + if r.get("assay_type") == "hemolysis_RBC": + hc50 = r.get("result_value", 0) + cid = r.get("candidate_id", "") + mic_results = [ + x for x in results + if x.get("candidate_id") == cid and x.get("assay_type") == "MIC" + ] + if mic_results: + mic_val = min(x.get("result_value", 999) for x in mic_results) + if mic_val > 0: + ti = hc50 / mic_val + if ti < toxic_threshold: + toxic_count += 1 + hemo_details.append(f"{cid}: TI={ti:.2f} (toxic)") + elif ti < hemo.get("ti_selective_threshold", 10): + hemo_details.append(f"{cid}: TI={ti:.2f} (borderline)") + else: + hemo_details.append(f"{cid}: TI={ti:.2f} (selective)") + + hemo_pass = toxic_count <= max_toxic + checks["hemolysis_safety"] = { + "pass": hemo_pass, + "detail": f"{toxic_count} toxic (TI < {toxic_threshold}), max allowed {max_toxic}. " + "; ".join(hemo_details[:5]), + } + if not hemo_pass: + all_pass = False + + # Batch-level: positive/negative controls passed + batch = criteria.get("batch_level", {}) + total_tested = len(set(r.get("candidate_id", "") for r in results)) + batch_pass = total_tested >= batch.get("min_total_candidates_tested", 1) + checks["batch_min_tested"] = { + "pass": batch_pass, + "detail": f"{total_tested} candidates tested (minimum {batch.get('min_total_candidates_tested', 1)})", + } + if not batch_pass: + all_pass = False + + # Secondary endpoint + sec = criteria.get("secondary_endpoint", {}) + sec_max_mic = sec.get("max_mic_ugml", 32) + sec_min_ti = sec.get("min_therapeutic_index", 10) + sec_min = sec.get("min_passing_candidates", 1) + sec_count = 0 + for r in results: + if r.get("assay_type") == "MIC" and r.get("result_value", 999) <= sec_max_mic: + cid = r.get("candidate_id", "") + hemo_results = [ + x for x in results + if x.get("candidate_id") == cid and x.get("assay_type") == "hemolysis_RBC" + ] + if hemo_results: + hc50 = hemo_results[0].get("result_value", 0) + mic_val = r.get("result_value", 999) + if mic_val > 0 and (hc50 / mic_val) > sec_min_ti: + sec_count += 1 + sec_pass = sec_count >= sec_min + checks["secondary_endpoint"] = { + "pass": sec_pass, + "detail": f"{sec_count}/{sec_min} candidates with MIC <= {sec_max_mic} and TI > {sec_min_ti}", + } + if not sec_pass: + all_pass = False + + return {"all_pass": all_pass, "checks": checks} + + +def main() -> int: + import argparse + parser = argparse.ArgumentParser(description="Check Wave 1 pass/fail criteria") + parser.add_argument("--results-dir", required=True, help="Directory with lab result JSONs") + parser.add_argument("--criteria", default="configs/wave1_pass_fail.yaml", help="Pass/fail criteria YAML") + parser.add_argument("--out", default=None, help="JSON output path") + args = parser.parse_args() + + try: + results = _load_lab_results(args.results_dir) + except FileNotFoundError as e: + print(f"Error: {e}", file=sys.stderr) + return 2 + + criteria_p = Path(args.criteria) + if not criteria_p.exists(): + print(f"Error: Criteria file not found: {args.criteria}", file=sys.stderr) + return 2 + + criteria = yaml.safe_load(criteria_p.read_text(encoding="utf-8")) + if not results: + print("Error: No lab results found", file=sys.stderr) + return 2 + + verdict = _check_criteria(results, criteria) + output = json.dumps(verdict, indent=2) + print(output) + + if args.out: + Path(args.out).write_text(output + "\n", encoding="utf-8") + + if verdict["all_pass"]: + print("\nAll criteria PASS") + return 0 + else: + print("\nOne or more criteria FAIL") + return 3 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_wave1_pass_fail.py b/tests/test_wave1_pass_fail.py new file mode 100644 index 00000000..b6947053 --- /dev/null +++ b/tests/test_wave1_pass_fail.py @@ -0,0 +1,213 @@ +"""Tests for the Wave 1 pass/fail criteria checker.""" + +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +from scripts.check_wave1_pass_fail import _check_criteria, _load_lab_results + + +def _make_result( + candidate_id: str, + assay_type: str, + value: float, + organism: str = "Escherichia coli ATCC 25922", + positive_control_passed: bool = True, +) -> dict: + return { + "result_id": f"{candidate_id}_{assay_type}", + "candidate_id": candidate_id, + "assay_type": assay_type, + "organism_or_cell_line": organism, + "result_value": value, + "result_unit": "ug/mL", + "positive_control_passed": positive_control_passed, + "negative_control_passed": True, + "assay_date": "2026-08-15", + "replicate_count": 3, + "performed_by_lab": "Test Lab", + "computational_candidate_certificate_hash": "abc123", + "disclaimer": "SYNTHETIC TEST DATA", + } + + +_MINIMAL_CRITERIA = { + "primary_endpoint": { + "organism": "Escherichia coli ATCC 25922", + "assay_type": "MIC", + "max_mic_ugml": 32, + "min_passing_candidates": 3, + }, + "secondary_endpoint": { + "max_mic_ugml": 32, + "min_therapeutic_index": 10, + "min_passing_candidates": 1, + }, + "positive_controls": [ + { + "candidate_id": "CTRL-001", + "expected_mic_min_ugml": 4, + "expected_mic_max_ugml": 32, + } + ], + "hemolysis": { + "assay_type": "hemolysis_RBC", + "ti_toxic_threshold": 2, + "ti_selective_threshold": 10, + "max_toxic_candidates": 0, + }, + "batch_level": { + "positive_control_passed": True, + "negative_control_passed": True, + "min_total_candidates_tested": 3, + "require_all_results_reported": True, + }, +} + + +def test_load_results_empty_dir(): + with tempfile.TemporaryDirectory() as tmp: + results = _load_lab_results(tmp) + assert len(results) == 0 + + +def test_load_results_finds_jsons(): + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + data = {"candidate_id": "T1", "assay_type": "MIC", "result_value": 8} + (d / "r1.json").write_text(json.dumps(data)) + (d / "r2.txt").write_text("not json") + results = _load_lab_results(tmp) + assert len(results) == 1 + + +def test_check_all_pass(): + results = [ + _make_result("C1", "MIC", 8), + _make_result("C2", "MIC", 16), + _make_result("C3", "MIC", 4), + _make_result("C1", "hemolysis_RBC", 320), + _make_result("C2", "hemolysis_RBC", 640), + _make_result("C3", "hemolysis_RBC", 160), + _make_result("CTRL-001", "MIC", 16), + ] + verdict = _check_criteria(results, _MINIMAL_CRITERIA) + assert verdict["all_pass"] is True + + +def test_check_mic_fails_when_too_few_pass(): + results = [ + _make_result("C1", "MIC", 64), + _make_result("C2", "MIC", 128), + _make_result("CTRL-001", "MIC", 16), + ] + verdict = _check_criteria(results, _MINIMAL_CRITERIA) + assert verdict["all_pass"] is False + assert verdict["checks"]["primary_endpoint_mic"]["pass"] is False + + +def test_check_positive_control_fails(): + results = [ + _make_result("C1", "MIC", 8), + _make_result("C2", "MIC", 16), + _make_result("C3", "MIC", 4), + ] + verdict = _check_criteria(results, _MINIMAL_CRITERIA) + assert verdict["all_pass"] is False + assert verdict["checks"]["positive_controls"]["pass"] is False + + +def test_check_hemolysis_toxic(): + results = [ + _make_result("C1", "MIC", 4), + _make_result("C1", "hemolysis_RBC", 2), + _make_result("CTRL-001", "MIC", 16), + ] + verdict = _check_criteria(results, _MINIMAL_CRITERIA) + assert verdict["all_pass"] is False + assert verdict["checks"]["hemolysis_safety"]["pass"] is False + + +def test_check_secondary_endpoint(): + results = [ + _make_result("C1", "MIC", 8), + _make_result("C1", "hemolysis_RBC", 320), + _make_result("C2", "MIC", 64), + _make_result("CTRL-001", "MIC", 16), + ] + verdict = _check_criteria(results, _MINIMAL_CRITERIA) + # C1: MIC=8, HC50=320, TI=40 > 10 → secondary passes + assert verdict["checks"]["secondary_endpoint"]["detail"] is not None + + +def test_cli_no_results_dir(): + result = subprocess.run( + [sys.executable, "scripts/check_wave1_pass_fail.py", + "--results-dir", "/nonexistent"], + capture_output=True, text=True, + env={"PYTHONPATH": "src"}, + ) + assert result.returncode == 2 + + +def test_cli_all_pass(tmp_path): + results_dir = tmp_path / "results" + results_dir.mkdir() + # Use the real positive control ID from the YAML config + (results_dir / "c1_mic.json").write_text(json.dumps(_make_result("C1", "MIC", 8))) + (results_dir / "c2_mic.json").write_text(json.dumps(_make_result("C2", "MIC", 16))) + (results_dir / "c3_mic.json").write_text(json.dumps(_make_result("C3", "MIC", 4))) + candidates = ["C1", "C2", "C3"] + [f"C{i}" for i in range(4, 21)] + for c in candidates: + (results_dir / f"{c}_mic.json").write_text( + json.dumps(_make_result(c, "MIC", 16))) + (results_dir / f"{c}_hemo.json").write_text( + json.dumps(_make_result(c, "hemolysis_RBC", 320))) + (results_dir / "ctrl.json").write_text( + json.dumps(_make_result("SEED-001_VAR_064", "MIC", 16))) + result = subprocess.run( + [sys.executable, "scripts/check_wave1_pass_fail.py", + "--results-dir", str(results_dir)], + capture_output=True, text=True, + env={"PYTHONPATH": "src"}, + ) + print(result.stdout) + print(result.stderr) + assert result.returncode == 0 + + +def test_cli_fails_when_mic_criteria_not_met(tmp_path): + results_dir = tmp_path / "results" + results_dir.mkdir() + (results_dir / "c1_mic.json").write_text(json.dumps(_make_result("C1", "MIC", 128))) + (results_dir / "ctrl.json").write_text(json.dumps(_make_result("CTRL-001", "MIC", 16))) + result = subprocess.run( + [sys.executable, "scripts/check_wave1_pass_fail.py", + "--results-dir", str(results_dir)], + capture_output=True, text=True, + env={"PYTHONPATH": "src"}, + ) + assert result.returncode == 3 + + +def test_cli_writes_output_json(tmp_path): + results_dir = tmp_path / "results" + results_dir.mkdir() + (results_dir / "c1_mic.json").write_text(json.dumps(_make_result("C1", "MIC", 8))) + (results_dir / "ctrl.json").write_text(json.dumps(_make_result("CTRL-001", "MIC", 16))) + out = tmp_path / "verdict.json" + result = subprocess.run( + [sys.executable, "scripts/check_wave1_pass_fail.py", + "--results-dir", str(results_dir), + "--out", str(out)], + capture_output=True, text=True, + env={"PYTHONPATH": "src"}, + ) + assert out.exists() + data = json.loads(out.read_text()) + assert "all_pass" in data + assert "checks" in data