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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions src/openamp_foundry/cli/commands/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 27 additions & 2 deletions src/openamp_foundry/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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=(
Expand Down Expand Up @@ -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)

Expand All @@ -743,4 +769,3 @@ def main(argv: list[str] | None = None) -> int:

if __name__ == "__main__":
raise SystemExit(main())

39 changes: 39 additions & 0 deletions src/openamp_foundry/data/lab_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
143 changes: 143 additions & 0 deletions src/openamp_foundry/reports/lab_result_report.py
Original file line number Diff line number Diff line change
@@ -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"
66 changes: 66 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading