From ee08bfd37ad2b34d9440c8119277d00a5575d6c2 Mon Sep 17 00:00:00 2001 From: j Date: Thu, 2 Jul 2026 03:13:34 +0700 Subject: [PATCH] feat: add wet-lab result review report --- README.md | 10 ++ docs/ARCHITECTURE.md | 6 +- docs/ROADMAP.md | 1 + src/openamp_foundry/cli/commands/reports.py | 27 ++++ src/openamp_foundry/cli/main.py | 29 +++- src/openamp_foundry/data/lab_results.py | 39 +++++ .../reports/lab_result_report.py | 143 ++++++++++++++++++ tests/test_cli.py | 66 ++++++++ tests/test_lab_results.py | 24 +++ 9 files changed, 341 insertions(+), 4 deletions(-) create mode 100644 src/openamp_foundry/reports/lab_result_report.py diff --git a/README.md b/README.md index f389ca8b..e4c4ba58 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,15 @@ python -m openamp_foundry.cli validate \ --schema schemas/candidate.schema.json ``` +Turn qualified assay JSON files into a reproducible review artifact: + +```bash +python -m openamp_foundry.cli lab-result-report \ + --results-dir outputs/lab_results \ + --out-json outputs/lab_result_report.json \ + --out-md outputs/lab_result_report.md +``` + ## Repository map ```text @@ -131,6 +140,7 @@ openamp-foundry/ docs/METRICS_CURRENT.md # current benchmark summary docs/PLAN.md # detailed execution plan docs/ROADMAP.md # shipped milestones and next horizons + docs/WET_LAB_HANDOFF.md # assay package and wet-lab review notes examples/ # toy datasets only models/README.md # model-release rules; no weights shipped outputs/.gitkeep # generated files ignored by git diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5f1b9fa1..7abbfbfd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -39,6 +39,7 @@ The purpose of the added layers is not simulation theater. It is to improve whic | `openamp_foundry.scoring` | activity, safety, hemolysis risk, novelty, synthesis, ensemble, expert composite scoring | | `openamp_foundry.selection` | ranking (ensemble or expert composite) and diversity selection | | `openamp_foundry.evidence` | JSON certificate generation and validation | +| `openamp_foundry.reports` | human-readable and machine-readable batch, calibration, and wet-lab review reports | | `openamp_foundry.benchmark` | leakage checks, cluster-split benchmark, expert ablation benchmark, within-AMP selectivity benchmark, triage benchmark, and evaluation scaffolding | | `openamp_foundry.generators` | safe, bounded toy candidate generation | | `openamp_foundry.simulation` | membrane/selectivity/stability proxy modeling (scaffolded) | @@ -88,8 +89,9 @@ When the project is mature enough, the extended loop should look like: 4. Estimate uncertainty and identify where the model is likely wrong. 5. Select a very small assay batch that balances likely winners with high-information probes. 6. Ingest qualified wet-lab outcomes through versioned schemas. -7. Recalibrate decision rules without rewriting success definitions after the fact. -8. Measure whether the added modeling layer actually reduced wasted experiments. +7. Convert raw result JSON into a control-aware review artifact before any recalibration decision. +8. Recalibrate decision rules without rewriting success definitions after the fact. +9. Measure whether the added modeling layer actually reduced wasted experiments. ## Extension points diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 34407c09..b0f49acd 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -239,6 +239,7 @@ Wet-lab hemolysis assay remains mandatory. - independently reviewed assay batch (expert_review.yml GitHub issue template); - lab results ingested via `schemas/lab_result.schema.json`; +- reproducible wet-lab result report via `openamp_foundry lab-result-report` so raw assay JSON turns into a control-aware review artifact before recalibration; - negative results archived where safe; - active-learning batch 2 (D-amino variants, MDR strain panel); - public reproducibility report. diff --git a/src/openamp_foundry/cli/commands/reports.py b/src/openamp_foundry/cli/commands/reports.py index f86c32f3..d4d535c9 100644 --- a/src/openamp_foundry/cli/commands/reports.py +++ b/src/openamp_foundry/cli/commands/reports.py @@ -6,6 +6,33 @@ from collections import defaultdict from datetime import datetime, timezone + +def _run_lab_result_report(args: argparse.Namespace) -> int: + from openamp_foundry.reports.lab_result_report import ( + build_lab_result_report, + write_lab_result_json, + write_lab_result_markdown, + ) + + report = build_lab_result_report(args.results_dir) + write_lab_result_json(report, args.out_json) + if args.out_md: + write_lab_result_markdown(report, args.out_md) + print( + json.dumps( + { + "status": "ok", + "n_results": report["summary"].get("n_results", 0), + "n_candidates": report.get("n_candidates", 0), + "n_control_failures": len(report.get("control_failures", [])), + "out_json": args.out_json, + "out_md": args.out_md, + }, + indent=2, + ) + ) + return 0 + def _run_reviewer_questionnaire(args: argparse.Namespace) -> int: import csv as _csv import json as _json diff --git a/src/openamp_foundry/cli/main.py b/src/openamp_foundry/cli/main.py index c666c7c2..1b1a3b12 100644 --- a/src/openamp_foundry/cli/main.py +++ b/src/openamp_foundry/cli/main.py @@ -5,7 +5,7 @@ from openamp_foundry.cli.commands.selection import _run_pilot_panel, _run_pilot_confident, _run_diversity_check from openamp_foundry.cli.commands.external import _run_external_predict, _run_external_consensus from openamp_foundry.cli.commands.qc import _run_synthesis_order, _run_presynth_qc -from openamp_foundry.cli.commands.reports import _run_reviewer_questionnaire, _run_ip_report, _run_batch_pack, _run_gold_standard, _run_novelty_check_broad +from openamp_foundry.cli.commands.reports import _run_reviewer_questionnaire, _run_ip_report, _run_batch_pack, _run_gold_standard, _run_novelty_check_broad, _run_lab_result_report from openamp_foundry.cli.commands.gates import _run_gate_check import argparse @@ -615,6 +615,29 @@ def build_parser() -> argparse.ArgumentParser: help="Similarity threshold for diversity clustering (default: 0.80)", ) + lab_result_report = sub.add_parser( + "lab-result-report", + help=( + "Build a reproducible summary report from validated wet-lab result JSON files. " + "This is a review artifact only, not a biological claim engine." + ), + ) + lab_result_report.add_argument( + "--results-dir", + required=True, + help="Directory containing lab result JSON files matching schemas/lab_result.schema.json.", + ) + lab_result_report.add_argument( + "--out-json", + required=True, + help="Output path for machine-readable report JSON.", + ) + lab_result_report.add_argument( + "--out-md", + required=False, + help="Optional output path for markdown review report.", + ) + novelty_broad = sub.add_parser( "novelty-check-broad", help=( @@ -722,6 +745,9 @@ def main(argv: list[str] | None = None) -> int: if args.command == "batch-pack": return _run_batch_pack(args) + if args.command == "lab-result-report": + return _run_lab_result_report(args) + if args.command == "synthesis-order": return _run_synthesis_order(args) @@ -743,4 +769,3 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": raise SystemExit(main()) - diff --git a/src/openamp_foundry/data/lab_results.py b/src/openamp_foundry/data/lab_results.py index cf5f3c86..e06906b3 100644 --- a/src/openamp_foundry/data/lab_results.py +++ b/src/openamp_foundry/data/lab_results.py @@ -104,3 +104,42 @@ def candidate_result_map(results: list[dict[str, Any]]) -> dict[str, list[dict[s cid = r["candidate_id"] mapping.setdefault(cid, []).append(r) return mapping + +def summarise_candidate_outcomes(results: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Build one row per candidate for decision-grade wet-lab review. + + The output stays descriptive. It does not convert assay readouts into + claims of efficacy or safety. + """ + rows: list[dict[str, Any]] = [] + for candidate_id, candidate_results in sorted(candidate_result_map(results).items()): + assay_types = sorted({r.get("assay_type", "other") for r in candidate_results}) + organisms = sorted({r.get("organism_or_cell_line", "unknown") for r in candidate_results}) + qualitative = [r.get("result_qualitative") or "unclassified" for r in candidate_results] + controls_failed = [ + r["result_id"] + for r in candidate_results + if not (r.get("positive_control_passed") and r.get("negative_control_passed")) + ] + numeric_results = [r for r in candidate_results if r.get("result_value") is not None] + dates = sorted(r.get("assay_date", "") for r in candidate_results) + + rows.append( + { + "candidate_id": candidate_id, + "n_results": len(candidate_results), + "assay_types": assay_types, + "organisms_or_cells": organisms, + "qualitative_results": qualitative, + "has_any_active": "active" in qualitative, + "has_any_toxic": "toxic" in qualitative, + "has_any_inconclusive": "inconclusive" in qualitative, + "all_controls_passed": len(controls_failed) == 0, + "control_fail_result_ids": controls_failed, + "max_replicate_count": max(r.get("replicate_count", 0) for r in candidate_results), + "first_assay_date": dates[0] if dates else None, + "last_assay_date": dates[-1] if dates else None, + "n_numeric_results": len(numeric_results), + } + ) + return rows diff --git a/src/openamp_foundry/reports/lab_result_report.py b/src/openamp_foundry/reports/lab_result_report.py new file mode 100644 index 00000000..ea69041b --- /dev/null +++ b/src/openamp_foundry/reports/lab_result_report.py @@ -0,0 +1,143 @@ +"""Wet-lab result reporting. + +Converts validated lab result JSON files into a reproducible summary artifact for +human review, calibration intake, and future active-learning loops. + +This module is descriptive only. It does not upgrade assay outcomes into drug, +efficacy, or safety claims. +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from openamp_foundry.data.lab_results import ( + candidate_result_map, + load_lab_results_dir, + summarise_candidate_outcomes, + summarise_lab_results, +) + + +def build_lab_result_report(results_dir: str | Path) -> dict[str, Any]: + """Build a machine-readable wet-lab result report from a directory of JSON files.""" + results = load_lab_results_dir(results_dir) + summary = summarise_lab_results(results) + by_candidate = summarise_candidate_outcomes(results) + controls_failed = [ + { + "result_id": r["result_id"], + "candidate_id": r["candidate_id"], + "assay_type": r["assay_type"], + "positive_control_passed": r["positive_control_passed"], + "negative_control_passed": r["negative_control_passed"], + } + for r in results + if not (r.get("positive_control_passed") and r.get("negative_control_passed")) + ] + by_lab: dict[str, int] = {} + for r in results: + lab = r.get("performed_by_lab", "unknown") + by_lab[lab] = by_lab.get(lab, 0) + 1 + + return { + "summary": summary, + "by_candidate": by_candidate, + "control_failures": controls_failed, + "by_lab": dict(sorted(by_lab.items())), + "n_candidates": len(candidate_result_map(results)), + "report_disclaimer": ( + "Wet-lab result report. Experimental observations on computationally nominated " + "candidates only. Not proof of efficacy, safety, clinical utility, or novelty. " + "Qualified expert review and independent replication remain mandatory." + ), + } + + +def write_lab_result_markdown(report: dict[str, Any], out_path: str | Path) -> None: + """Write a human-readable markdown report.""" + p = Path(out_path) + p.parent.mkdir(parents=True, exist_ok=True) + s = report["summary"] + + lines = [ + "# Wet-Lab Result Report", + "", + report["report_disclaimer"], + "", + "## Batch Summary", + "", + f"- Results loaded: {s.get('n_results', 0)}", + f"- Candidates covered: {report.get('n_candidates', 0)}", + f"- Results with both controls passing: {s.get('n_valid_controls', 0)}", + "", + "## Assay Type Counts", + "", + "| Assay type | Count |", + "|---|---:|", + ] + for assay_type, count in sorted(s.get("by_assay_type", {}).items()): + lines.append(f"| {assay_type} | {count} |") + + lines += [ + "", + "## Qualitative Outcome Counts", + "", + "| Outcome | Count |", + "|---|---:|", + ] + for outcome, count in sorted(s.get("by_qualitative_result", {}).items()): + lines.append(f"| {outcome} | {count} |") + + lines += [ + "", + "## Per-Candidate Rollup", + "", + "| Candidate | Results | Assays | Any active | Any toxic | Controls ok | Window |", + "|---|---:|---|---|---|---|---|", + ] + for row in report.get("by_candidate", []): + lines.append( + f"| {row['candidate_id']} | {row['n_results']} | {', '.join(row['assay_types'])} | " + f"{_yes_no(row['has_any_active'])} | {_yes_no(row['has_any_toxic'])} | " + f"{_yes_no(row['all_controls_passed'])} | {row['first_assay_date']} to {row['last_assay_date']} |" + ) + + lines += ["", "## Control Failures", ""] + failures = report.get("control_failures", []) + if failures: + lines += [ + "| Result ID | Candidate | Assay | Positive control | Negative control |", + "|---|---|---|---|---|", + ] + for item in failures: + lines.append( + f"| {item['result_id']} | {item['candidate_id']} | {item['assay_type']} | " + f"{_yes_no(item['positive_control_passed'])} | {_yes_no(item['negative_control_passed'])} |" + ) + else: + lines.append("No control failures recorded.") + + lines += [ + "", + "## Next Review Questions", + "", + "- Which candidates remain interesting after removing any result with failed controls?", + "- Which assay readouts need repeat runs before they can affect selection policy?", + "- Do the observed outcomes support recalibration, or is the batch still too small or noisy?", + "", + ] + + p.write_text("\n".join(lines), encoding="utf-8") + + +def write_lab_result_json(report: dict[str, Any], out_path: str | Path) -> None: + """Write the machine-readable wet-lab result report.""" + p = Path(out_path) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(report, indent=2), encoding="utf-8") + + +def _yes_no(value: bool) -> str: + return "yes" if value else "no" diff --git a/tests/test_cli.py b/tests/test_cli.py index 72496af5..cb5a7fa6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -555,6 +555,72 @@ def test_batch_pack_creates_json_output(tmp_path, capsys): assert (tmp_path / "batch.md").exists() +def test_lab_result_report_creates_outputs(tmp_path, capsys): + results_dir = tmp_path / "lab_results" + results_dir.mkdir() + base = { + "assay_type": "MIC", + "organism_or_cell_line": "E. coli ATCC 25922", + "result_value": 4.0, + "result_unit": "µg/mL", + "positive_control_id": "ciprofloxacin 0.25 µg/mL", + "negative_control_id": "PBS", + "assay_date": "2026-07-01", + "replicate_count": 3, + "performed_by_lab": "University Test Lab", + "raw_data_sha256": None, + "computational_candidate_certificate_hash": "abc123def456", + "notes": None, + "disclaimer": ( + "This is an experimental result on a computationally nominated candidate. " + "It does not constitute a drug or clinical claim." + ), + } + good = { + **base, + "result_id": "RES-001", + "candidate_id": "CAND-001", + "result_qualitative": "active", + "positive_control_passed": True, + "negative_control_passed": True, + } + failed_control = { + **base, + "result_id": "RES-002", + "candidate_id": "CAND-001", + "assay_type": "hemolysis_RBC", + "result_value": 18.0, + "result_unit": "%", + "result_qualitative": "toxic", + "positive_control_passed": False, + "negative_control_passed": True, + } + (results_dir / "res1.json").write_text(json.dumps(good), encoding="utf-8") + (results_dir / "res2.json").write_text(json.dumps(failed_control), encoding="utf-8") + + out_json = tmp_path / "lab_report.json" + out_md = tmp_path / "lab_report.md" + rc = main([ + "lab-result-report", + "--results-dir", str(results_dir), + "--out-json", str(out_json), + "--out-md", str(out_md), + ]) + assert rc == 0 + captured = json.loads(capsys.readouterr().out) + assert captured["status"] == "ok" + assert captured["n_results"] == 2 + assert captured["n_control_failures"] == 1 + report = json.loads(out_json.read_text(encoding="utf-8")) + assert report["summary"]["n_results"] == 2 + assert report["n_candidates"] == 1 + assert len(report["control_failures"]) == 1 + text = out_md.read_text(encoding="utf-8") + assert "Wet-Lab Result Report" in text + assert "CAND-001" in text + assert "RES-002" in text + + class TestNoveltyCheckBroad: def test_novelty_check_broad_returns_zero(self, tmp_path, capsys): panel_csv = _write_panel(tmp_path) diff --git a/tests/test_lab_results.py b/tests/test_lab_results.py index 3fd2d885..cc596d2d 100644 --- a/tests/test_lab_results.py +++ b/tests/test_lab_results.py @@ -18,6 +18,7 @@ candidate_result_map, load_lab_result, load_lab_results_dir, + summarise_candidate_outcomes, summarise_lab_results, ) @@ -177,6 +178,29 @@ def test_empty_results_gives_empty_map(self): assert candidate_result_map([]) == {} +class TestSummariseCandidateOutcomes: + def test_rolls_up_candidate_status(self): + results = [ + _valid_result(result_id="R1", candidate_id="CAND-001", result_qualitative="active"), + _valid_result( + result_id="R2", + candidate_id="CAND-001", + assay_type="hemolysis_RBC", + result_qualitative="toxic", + positive_control_passed=False, + ), + _valid_result(result_id="R3", candidate_id="CAND-002", result_qualitative="inactive"), + ] + rows = summarise_candidate_outcomes(results) + by_id = {row["candidate_id"]: row for row in rows} + assert by_id["CAND-001"]["n_results"] == 2 + assert by_id["CAND-001"]["has_any_active"] is True + assert by_id["CAND-001"]["has_any_toxic"] is True + assert by_id["CAND-001"]["all_controls_passed"] is False + assert by_id["CAND-001"]["control_fail_result_ids"] == ["R2"] + assert by_id["CAND-002"]["all_controls_passed"] is True + + class TestLoadLabResultsDir: def test_empty_directory_returns_empty_list(self, tmp_path): results = load_lab_results_dir(tmp_path)