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
69 changes: 69 additions & 0 deletions configs/wave1_pass_fail.yaml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions docs/50_LOOP_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,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 |
Expand Down Expand Up @@ -242,7 +242,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
Expand Down
207 changes: 207 additions & 0 deletions scripts/check_wave1_pass_fail.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading