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 docs/evidence/BATCH_OUTCOME_SUMMARY_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Batch Outcome Summary Guide

## Purpose

A Batch Outcome Summary (BOS) documents the aggregate experimental outcomes for a
batch that was originally proposed by a Batch Selection Proposal (BSP).

The BOS closes the loop from computational proposal to experimental results, making
the feedback cycle auditable:

```
BSP (proposed batch)
→ Lab experiments
→ BOS (aggregate outcomes)
→ CPS (calibration performance summary)
→ CBA (cross-batch aggregator)
→ CRG (calibration readiness gate)
→ BSP (next batch proposal)
```

## Key constraint: synthetic/real boundary

`is_synthetic=True` requires `dry_lab_only=True`. Synthetic results (mock data,
simulated experiments) cannot be treated as real lab data. This prevents synthetic
validation from being confused with actual experimental validation.

## Schema fields

| Field | Type | Description |
|---|---|---|
| `bos_id` | str | Must start with `BOS-` |
| `pipeline_version` | str | Pipeline version at time of batch execution |
| `bsp_id` | str | Must start with `BSP-` — the proposal this responds to |
| `batch_id` | str | Batch identifier |
| `candidates_proposed` | int | Number of candidates in the original BSP |
| `candidates_tested` | int | Number actually tested (≤ proposed) |
| `candidates_active` | int | Number showing activity |
| `candidates_inactive` | int | Number showing no activity |
| `is_synthetic` | bool | True if results are synthetic/simulated |
| `outcome_notes` | str | Max 400 chars |
| `reviewer` | str | Who validated these outcomes |
| `dry_lab_only` | bool | Must be True when is_synthetic=True |

## Count constraint

`candidates_active + candidates_inactive` must equal `candidates_tested`.
The difference `candidates_proposed - candidates_tested` is computed as
`candidates_untested` and reported in the validation result.

## Honest boundaries

- A BOS records aggregate counts — it does not document which specific candidates
were active or inactive (use CalibrationIntakeEntry per candidate for that).
- `is_synthetic=True` entries cannot support biological claims even if values
look favorable — label them clearly in reports.
- The BOS does not validate that `bsp_id` references a real BSP; referential
integrity requires a separate audit step.
- Untested candidates reduce calibration coverage; a BOS should explain why
candidates were not tested in `outcome_notes`.

## Warnings

The validator emits warnings (not errors) for:

- Any untested candidates (fraction ≥ 25%: calibration coverage warning; otherwise
generic warning).
- `is_synthetic=False` with `dry_lab_only=True` — real results should set
`dry_lab_only=False` after safety review.
- No active candidates — worth investigating if unexpected.
1 change: 1 addition & 0 deletions docs/evidence/METRICS_CURRENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1231,6 +1231,7 @@ Decoys score low on activity. Selective AMPs score moderately on both.

| Date | Change | Author |
|------|--------|--------|
| 2026-07-10 | **Batch outcome summary schema (Phase P P3):** BatchOutcomeSummaryEntry (12 fields: bos_id, pipeline_version, bsp_id, batch_id, candidates_proposed, candidates_tested, candidates_active, candidates_inactive, is_synthetic, outcome_notes, reviewer, dry_lab_only). 9 validation rules (BOS-/BSP- prefixes, count constraints, synthetic/real boundary: is_synthetic=True requires dry_lab_only=True, notes<=400 chars). 3 warnings (untested candidates, real results marked dry-lab-only, zero active). 63 tests. CLI: openamp-foundry batch-outcome-summary-check. Closes the BSP-to-outcomes feedback loop. BASELINE 4784->4847. | OpenAMP Loop |
| 2026-07-10 | **Recalibration refusal record schema (Phase P P2):** RecalibrationRefusalEntry (10 fields, recalibration_refused must be True). 7 validation rules, 2 warnings. 63 tests. CLI: openamp-foundry recalibration-refusal-check. Complements CalibrationImprovementRecord (O3) with the rejection path. Corrected inflated BASELINE 4889->4784. | OpenAMP Loop |
| 2026-07-09 | **Release checklist and gate validator (J1, starts Phase J):** `docs/governance/RELEASE_CHECKLIST.md` with structured checklist. `src/openamp_foundry/governance/release_gate.py` with `RELEASE_TYPES` (5), `UNIVERSAL_GATES` (7), `EXTRA_GATES_BY_TYPE`, `ReleaseGateResult`, `validate_release_gate()`. CLI `release-gate-check`. `make release-gate-check`. 18 tests. **Phase J (Governance and release maturity) started. 3478 total.** | OpenAMP Loop 109 |
| 2026-07-09 | **Adoption scorecard dashboard added (I10):** `src/openamp_foundry/adoption/scorecard.py` with `SCORECARD_DIMENSIONS` (5, weights sum 1.0), `ADOPTION_TIERS` (4), `DimensionScore`, `AdoptionScorecard`, `build_scorecard()`, `compute_adoption_tier()`. CLI `adoption-scorecard` with `--scores-json` and `--format`. `make adoption-scorecard`. 17 tests. **Phase I (Interoperability and Adoption) is now complete** — all 10 items I1–I10 implemented. **3446 total.** | OpenAMP Loop 108 |
Expand Down
1 change: 1 addition & 0 deletions docs/research/NEXT_100_PR_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ preventing the pipeline from selecting candidates under poor calibration.
|---:|---|---|---|
| P1 | Add batch selection proposal schema (complete). | Enforces calibration gate must pass before proposing a next batch; documents exploitation/exploration strategy. | B/D |
| P2 | Add recalibration refusal record schema (complete). | Documents when recalibration was correctly rejected; prevents spurious recalibrations and creates audit trail. | B/D |
| P3 | Add batch outcome summary schema (complete). | Closes the BSP→lab→outcomes feedback loop; enforces synthetic/real boundary at the batch level. | B/D |

## Prioritization rule

Expand Down
32 changes: 32 additions & 0 deletions src/openamp_foundry/cli/commands/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -3634,3 +3634,35 @@ def _run_recalibration_refusal_check(args) -> int:
for warn in result.warnings:
print(f" WARN: {warn}")
return 0 if result.passed else 1


def _run_batch_outcome_summary_check(args) -> int:
import json
from openamp_foundry.evidence.batch_outcome_summary import (
validate_batch_outcome_summary_dict,
)
if args.entry_json:
try:
data = json.loads(args.entry_json)
except json.JSONDecodeError as exc:
print(f"Error: invalid JSON: {exc}", file=__import__("sys").stderr)
return 1
else:
data = json.load(__import__("sys").stdin)
result = validate_batch_outcome_summary_dict(data)
if args.format == "json":
import dataclasses
print(json.dumps(dataclasses.asdict(result), indent=2))
else:
status = "PASS" if result.passed else "FAIL"
synth = "SYNTHETIC" if result.is_synthetic else "REAL"
print(f"[{status}] Batch Outcome Summary: {result.bos_id} ({synth})")
print(f" BSP: {result.bsp_id}")
print(f" Tested: {result.candidates_tested}/{result.candidates_proposed} "
f"(active={result.candidates_active}, inactive={result.candidates_inactive}, "
f"untested={result.candidates_untested})")
for err in result.errors:
print(f" ERROR: {err}")
for warn in result.warnings:
print(f" WARN: {warn}")
return 0 if result.passed else 1
12 changes: 12 additions & 0 deletions src/openamp_foundry/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
_run_calibration_readiness_check,
_run_batch_selection_proposal_check,
_run_recalibration_refusal_check,
_run_batch_outcome_summary_check,
)
from openamp_foundry.cli.commands.gates import _run_gate_check, _run_release_gate_check
from openamp_foundry.cli.commands.reports import _run_contribution_check
Expand Down Expand Up @@ -2374,6 +2375,14 @@ def build_parser() -> argparse.ArgumentParser:
p_rrf.add_argument("--format", choices=["text", "json"], default="text")
p_rrf.set_defaults(func=_run_recalibration_refusal_check)

p_bos = sub.add_parser(
"batch-outcome-summary-check",
help="Validate batch outcome summary entry",
)
p_bos.add_argument("--entry-json", default=None)
p_bos.add_argument("--format", choices=["text", "json"], default="text")
p_bos.set_defaults(func=_run_batch_outcome_summary_check)

# ── Selection rationale check (Phase K K1) ───────────────────────
src2 = sub.add_parser(
"selection-rationale-check",
Expand Down Expand Up @@ -2755,6 +2764,9 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "recalibration-refusal-check":
return _run_recalibration_refusal_check(args)

if args.command == "batch-outcome-summary-check":
return _run_batch_outcome_summary_check(args)

parser.error("unknown command")
return 2

Expand Down
219 changes: 219 additions & 0 deletions src/openamp_foundry/evidence/batch_outcome_summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
"""Batch outcome summary schema — Phase P P3.

Documents aggregate experimental outcomes for a batch proposed by a
BatchSelectionProposal (BSP). This schema closes the loop from proposal
to results and enforces the synthetic/real evidence boundary.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import List

BOS_PREFIX = "BOS-"
BSP_PREFIX = "BSP-"
OUTCOME_NOTES_MAX_LENGTH = 400
UNTESTED_WARNING_THRESHOLD = 0.25


@dataclass
class BatchOutcomeSummaryEntry:
"""Aggregate experimental outcomes for a proposed batch."""

bos_id: str
pipeline_version: str
bsp_id: str
batch_id: str
candidates_proposed: int
candidates_tested: int
candidates_active: int
candidates_inactive: int
is_synthetic: bool
outcome_notes: str
reviewer: str
dry_lab_only: bool = True


@dataclass
class BatchOutcomeSummaryResult:
bos_id: str
pipeline_version: str
bsp_id: str
batch_id: str
candidates_proposed: int
candidates_tested: int
candidates_active: int
candidates_inactive: int
candidates_untested: int
is_synthetic: bool
passed: bool
errors: List[str]
warnings: List[str]
dry_lab_only: bool = True


def validate_batch_outcome_summary(
entry: BatchOutcomeSummaryEntry,
) -> BatchOutcomeSummaryResult:
errors: List[str] = []
warnings: List[str] = []

if not entry.bos_id.startswith(BOS_PREFIX):
errors.append(
f"bos_id must start with '{BOS_PREFIX}', got '{entry.bos_id}'"
)

if not entry.bsp_id.startswith(BSP_PREFIX):
errors.append(
f"bsp_id must start with '{BSP_PREFIX}', got '{entry.bsp_id}'"
)

if entry.candidates_proposed < 1:
errors.append(
f"candidates_proposed must be >= 1, got {entry.candidates_proposed}"
)

if entry.candidates_tested < 0:
errors.append(
f"candidates_tested must be >= 0, got {entry.candidates_tested}"
)

if entry.candidates_active < 0:
errors.append(
f"candidates_active must be >= 0, got {entry.candidates_active}"
)

if entry.candidates_inactive < 0:
errors.append(
f"candidates_inactive must be >= 0, got {entry.candidates_inactive}"
)

# active + inactive must equal tested
if entry.candidates_active + entry.candidates_inactive != entry.candidates_tested:
errors.append(
f"candidates_active ({entry.candidates_active}) + "
f"candidates_inactive ({entry.candidates_inactive}) must equal "
f"candidates_tested ({entry.candidates_tested})"
)

# tested cannot exceed proposed
if entry.candidates_tested > entry.candidates_proposed:
errors.append(
f"candidates_tested ({entry.candidates_tested}) cannot exceed "
f"candidates_proposed ({entry.candidates_proposed})"
)

# synthetic/dry_lab_only consistency
if entry.is_synthetic and not entry.dry_lab_only:
errors.append(
"is_synthetic=True requires dry_lab_only=True — synthetic results "
"cannot be treated as real lab data"
)

if len(entry.outcome_notes) > OUTCOME_NOTES_MAX_LENGTH:
errors.append(
f"outcome_notes exceeds {OUTCOME_NOTES_MAX_LENGTH} characters "
f"(got {len(entry.outcome_notes)})"
)

candidates_untested = max(
0, entry.candidates_proposed - entry.candidates_tested
)

# Warnings
if (
candidates_untested > 0
and entry.candidates_proposed > 0
and not errors
):
fraction_untested = candidates_untested / entry.candidates_proposed
if fraction_untested >= UNTESTED_WARNING_THRESHOLD:
warnings.append(
f"{candidates_untested}/{entry.candidates_proposed} candidates "
f"({fraction_untested:.0%}) were not tested — calibration coverage "
"may be reduced"
)
else:
warnings.append(
f"{candidates_untested} candidate(s) from the proposal were not tested"
)

if not entry.is_synthetic and entry.dry_lab_only and not errors:
warnings.append(
"is_synthetic=False (real results) but dry_lab_only=True — "
"consider setting dry_lab_only=False for real experimental outcomes"
)

if entry.candidates_active == 0 and entry.candidates_tested > 0 and not errors:
warnings.append(
"no active candidates in this batch — consider whether this is "
"expected given the selection strategy"
)

return BatchOutcomeSummaryResult(
bos_id=entry.bos_id,
pipeline_version=entry.pipeline_version,
bsp_id=entry.bsp_id,
batch_id=entry.batch_id,
candidates_proposed=entry.candidates_proposed,
candidates_tested=entry.candidates_tested,
candidates_active=entry.candidates_active,
candidates_inactive=entry.candidates_inactive,
candidates_untested=candidates_untested,
is_synthetic=entry.is_synthetic,
passed=len(errors) == 0,
errors=errors,
warnings=warnings,
dry_lab_only=entry.dry_lab_only,
)


def validate_batch_outcome_summary_dict(d: dict) -> BatchOutcomeSummaryResult:
missing = []
for k in (
"bos_id",
"pipeline_version",
"bsp_id",
"batch_id",
"candidates_proposed",
"candidates_tested",
"candidates_active",
"candidates_inactive",
"is_synthetic",
"outcome_notes",
"reviewer",
):
if k not in d:
missing.append(k)
if missing:
return BatchOutcomeSummaryResult(
bos_id=d.get("bos_id", ""),
pipeline_version=d.get("pipeline_version", ""),
bsp_id=d.get("bsp_id", ""),
batch_id=d.get("batch_id", ""),
candidates_proposed=d.get("candidates_proposed", 0),
candidates_tested=d.get("candidates_tested", 0),
candidates_active=d.get("candidates_active", 0),
candidates_inactive=d.get("candidates_inactive", 0),
candidates_untested=0,
is_synthetic=d.get("is_synthetic", True),
passed=False,
errors=[f"missing required fields: {missing}"],
warnings=[],
dry_lab_only=d.get("dry_lab_only", True),
)
entry = BatchOutcomeSummaryEntry(
bos_id=d["bos_id"],
pipeline_version=d["pipeline_version"],
bsp_id=d["bsp_id"],
batch_id=d["batch_id"],
candidates_proposed=int(d["candidates_proposed"]),
candidates_tested=int(d["candidates_tested"]),
candidates_active=int(d["candidates_active"]),
candidates_inactive=int(d["candidates_inactive"]),
is_synthetic=bool(d["is_synthetic"]),
outcome_notes=d["outcome_notes"],
reviewer=d["reviewer"],
dry_lab_only=bool(d.get("dry_lab_only", True)),
)
return validate_batch_outcome_summary(entry)
Loading
Loading