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
11 changes: 10 additions & 1 deletion docs/evidence/METRICS_CURRENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@ 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-10 (Phase O O3 — calibration improvement record — v0.10.18)
> **Last updated:** 2026-07-10 (Phase K K4 — post-experiment calibration intake — v0.10.19)
> **New in v0.10.19 — Phase K K4 — Post-Experiment Calibration Intake (PCI-)**

> - **Schema**: `PostExperimentCalibrationIntake` — 13 fields, structured result-to-prediction comparison
> - **Validation**: 12 rules — PCI- prefix, pipeline_version+batch_id non-empty, ISO date, candidates_tested ≥1, candidates_with_results in [1,tested], observed_active in [0,with_results], hit rate in [0,1], hit rate consistency (tol 0.01), rationale non-empty and ≤400 chars, data_quality_confirmed=True enforced, notes ≤300 chars
> - **Warnings**: 3 — incomplete results (some candidates without results), observed_active=0, low hit rate without calibration update
> - **Tests**: 63 tests across 9 test classes; BASELINE 5792→5855
> - **CLI**: `openamp-foundry post-experiment-calibration-intake-check`
> - **Closes wet-lab loop**: PCI- feeds CIR- (calibration improvement) and P3/P5 (cycle summary)

> **New in v0.10.18 (Phase O O3 — Calibration Improvement Record (CIR-)):** CalibrationImprovementRecord — 12 fields, before/after audit record for calibration updates. 11 validation rules — CIR- prefix, pipeline_version non-empty, calibration_version_before/after non-empty and must differ, ISO date, metric_name controlled vocab (8 values), improvement_confirmed=True enforced, improvement_rationale non-empty and ≤400 chars, data_source_id non-empty, notes ≤300 chars. 3 warnings — wrong direction for higher-is-better/lower-is-better metrics, very small improvement (<0.005), notes empty. 63 tests across 9 test classes; BASELINE 5729→5792. CLI: openamp-foundry calibration-improvement-record-check. Completes Phase O: joins O1/O2/O4/O5 with the missing before/after change record.
> **New in v0.10.17 (Phase K K2 — Batch Experiment Priority Ranker):** BatchExperimentPriorityRanker — 11 fields, synthesis wave priority ordering. 10 validation rules — BPR- prefix, pipeline_version+batch_id non-empty, CSR- prefix, ISO date, priority_method controlled vocab (6 values), top_priority_candidates non-empty, priority_rationale non-empty and ≤400 chars, synthesis_wave ≥1, notes ≤300 chars. 3 warnings — resource_constraint_considered=False, expert_ranked without notes, synthesis_wave >5. 63 tests across 9 test classes; BASELINE 5666→5729. CLI: openamp-foundry batch-experiment-priority-ranker-check. BPR- records which candidates to synthesize first; CSR- records why candidates were selected.
> **New in v0.10.16 (Phase K K1 — Candidate Selection Rationale):** CandidateSelectionRationale — 12 fields, auditable candidate selection decisions. 12 validation rules: CSR- prefix, pipeline_version+batch_id non-empty, BSP- prefix, ISO date, strategy controlled vocab (4 values), candidate_count ≥1, count/ids consistency, ranking_method controlled vocab (6 values), calibration_gate_passed=True enforced, rationale non-empty and ≤400 chars, notes ≤300 chars. 3 warnings: large batch (>20), random_balanced without notes, expert_review without notes. 63 tests; BASELINE 5603→5666. CLI: openamp-foundry candidate-selection-rationale-check.
Expand Down
78 changes: 78 additions & 0 deletions docs/evidence/POST_EXPERIMENT_CALIBRATION_INTAKE_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Post-Experiment Calibration Intake Guide (PCI-)

## Purpose

A `PostExperimentCalibrationIntake` (PCI-) is the structured result-to-prediction
comparison record created after wet-lab experiments complete. It captures:
- How many candidates were tested and how many returned results
- How many showed activity vs. the model's predicted activity count
- The empirical hit rate and whether it is consistent with the stated value
- Whether calibration update is warranted based on the results

Without PCI-, experimental results cannot be systematically compared to predictions,
and the calibration cycle has no structured entry point.

## Fields

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `pci_id` | str | Yes | Unique ID, must start with `PCI-` |
| `pipeline_version` | str | Yes | Version of the pipeline that generated predictions |
| `batch_id` | str | Yes | Batch that was experimentally tested |
| `experiment_date` | str | Yes | ISO date (YYYY-MM-DD) when experiments completed |
| `candidates_tested` | int | Yes | Total candidates sent to the lab (≥1) |
| `candidates_with_results` | int | Yes | Candidates that returned a result (≥1, ≤tested) |
| `predicted_active_count` | int | Yes | Number the model predicted would be active |
| `observed_active_count` | int | Yes | Number actually observed as active (≥0, ≤with_results) |
| `prediction_hit_rate` | float | Yes | observed_active/candidates_with_results ∈ [0, 1] |
| `calibration_update_warranted` | bool | Yes | Whether results justify updating calibration |
| `calibration_update_rationale` | str | Yes | Why update is/isn't warranted (≤400 chars) |
| `data_quality_confirmed` | bool | Yes | Must be True; data quality must be verified first |
| `notes` | str | No | Additional context (≤300 chars) |

## Validation Rules

1. `pci_id` must start with `PCI-`
2. `pipeline_version` must be non-empty
3. `batch_id` must be non-empty
4. `experiment_date` must match `YYYY-MM-DD`
5. `candidates_tested` must be ≥1
6. `candidates_with_results` must be ≥1 and ≤`candidates_tested`
7. `observed_active_count` must be ≥0 and ≤`candidates_with_results`
8. `prediction_hit_rate` must be in [0, 1]
9. `prediction_hit_rate` must be consistent with `observed_active_count / candidates_with_results` (tolerance 0.01)
10. `calibration_update_rationale` must be non-empty and ≤400 chars
11. `data_quality_confirmed` must be True
12. `notes` must be ≤300 chars

## Warnings

| Condition | Warning |
|-----------|---------|
| `candidates_with_results < candidates_tested` | Some candidates have no results; verify they are documented |
| `observed_active_count == 0` | No active candidates found; verify experimental conditions |
| `calibration_update_warranted=False` and `prediction_hit_rate < 0.3` | Low hit rate without calibration update; consider whether update is needed |

## Boundaries

- **PCI- records dry-lab-to-wet-lab comparison.** `prediction_hit_rate` is based on wet-lab results, not computational predictions.
- **`data_quality_confirmed` must be True.** Do not record a PCI- unless the experimental data has been quality-checked.
- **One PCI- per batch.** If a batch is run in multiple experiment waves, create separate PCI- records per wave.
- **Hit rate consistency is validated.** `prediction_hit_rate` must equal `observed_active_count / candidates_with_results` within 0.01.

## CLI

```bash
openamp-foundry post-experiment-calibration-intake-check '{"pci_id": "PCI-001", ...}'
```

Returns `VALID` or `INVALID` with any errors and warnings.

## Relationship to Other Schemas

```
BSP- ──→ PCI- (batch selection proposal identified the candidates tested)
PCI- ──→ CIR- (calibration improvement record documents the resulting update)
PCI- ──→ P3 (batch outcome summary references PCI- data)
PCI- ──→ P5 (calibration cycle summary includes PCI- as intake evidence)
```
2 changes: 1 addition & 1 deletion docs/research/NEXT_100_PR_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ Build the evidence and accountability artifacts needed for credible external val
| K1 | Add candidate selection rationale schema (complete). | CSR- records why specific candidates were selected: strategy (4 values), ranking method (6 values), calibration gate enforced, candidate_count/ids consistency check. | B |
| K2 | Add batch experiment priority ranker (complete). | BPR- records synthesis wave priority ordering: 6 priority methods, synthesis_wave ≥1, resource_constraint_considered enforced, CSR- reference. | B |
| K3 | Add pilot package completeness checker (complete). | PPC- completeness gate: confirms CCS-+BSP-+PSC-+PRE-+BCM- all present and ESC- cleared before external sharing. | B |
| K4 | Add post-experiment calibration intake schema. | Structured result-to-prediction comparison. | B/D |
| K4 | Add post-experiment calibration intake schema (complete). | PCI- closes the wet-lab→calibration loop: hit rate consistency check, data_quality_confirmed enforced, candidates_tested/with_results/active counts validated. | B/D |
| K5 | Add uncertainty quantification report schema (complete — Loop 123). | Honest prediction intervals for reviewers. | B |

## Phase L — Scientific Communication
Expand Down
22 changes: 22 additions & 0 deletions src/openamp_foundry/cli/commands/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -2970,3 +2970,25 @@ def _run_calibration_improvement_record_check(args):
for w in result.warnings:
print(f"WARNING: {w}")
sys.exit(1)


def _run_post_experiment_calibration_intake_check(args):
import json, sys
from openamp_foundry.evidence.post_experiment_calibration_intake import validate_dict
try:
data = json.loads(args.json_input)
except json.JSONDecodeError as exc:
print(f"INVALID: JSON parse error: {exc}", file=sys.stderr)
sys.exit(1)
result = validate_dict(data)
if result.valid:
print("VALID")
for w in result.warnings:
print(f"WARNING: {w}")
else:
print("INVALID")
for e in result.errors:
print(f"ERROR: {e}")
for w in result.warnings:
print(f"WARNING: {w}")
sys.exit(1)
11 changes: 11 additions & 0 deletions src/openamp_foundry/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
from openamp_foundry.evidence.candidate_selection_rationale import validate_dict as validate_candidate_selection_rationale_dict
from openamp_foundry.evidence.negative_result_entry import validate_dict as validate_negative_result_entry_dict
from openamp_foundry.evidence.pilot_package_completeness_report import validate_dict as validate_pilot_package_completeness_dict
from openamp_foundry.evidence.post_experiment_calibration_intake import validate_dict as validate_post_experiment_calibration_intake_dict
from openamp_foundry.evidence.quality import assess_certificate_quality
from openamp_foundry.evidence.schemas import validate_json_schema
from openamp_foundry.pipeline import run_ranking_pipeline
Expand Down Expand Up @@ -2514,6 +2515,12 @@ def build_parser() -> argparse.ArgumentParser:
)
cir_parser.add_argument("json_input", help="JSON string of the CIR- record")

pci_parser = subparsers.add_parser(
"post-experiment-calibration-intake-check",
help="Validate a PostExperimentCalibrationIntake (PCI-) record",
)
pci_parser.add_argument("json_input", help="JSON string of the PCI- record")

# ── Selection rationale check (Phase K K1) ───────────────────────
src2 = sub.add_parser(
"selection-rationale-check",
Expand Down Expand Up @@ -2948,6 +2955,10 @@ def main(argv: list[str] | None = None) -> int:
from openamp_foundry.cli.commands.reports import _run_calibration_improvement_record_check
_run_calibration_improvement_record_check(args)

elif args.command == "post-experiment-calibration-intake-check":
from openamp_foundry.cli.commands.reports import _run_post_experiment_calibration_intake_check
_run_post_experiment_calibration_intake_check(args)

else:
parser.error("unknown command")
return 2
Expand Down
177 changes: 177 additions & 0 deletions src/openamp_foundry/evidence/post_experiment_calibration_intake.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""Post-experiment calibration intake schema — Phase K K4.

Structured result-to-prediction comparison record after wet-lab experiments.
Captures candidates tested, observed activity, hit rate, and whether calibration
update is warranted. Closes the wet-lab→calibration feedback loop.
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field
from typing import List

PCI_PREFIX = "PCI-"

HIT_RATE_TOLERANCE = 0.01
RATIONALE_MAX_LENGTH = 400
NOTES_MAX_LENGTH = 300
LOW_HIT_RATE_THRESHOLD = 0.3
_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")


@dataclass
class PostExperimentCalibrationIntake:
pci_id: str
pipeline_version: str
batch_id: str
experiment_date: str
candidates_tested: int
candidates_with_results: int
predicted_active_count: int
observed_active_count: int
prediction_hit_rate: float
calibration_update_warranted: bool
calibration_update_rationale: str
data_quality_confirmed: bool
notes: str = ""


@dataclass
class PostExperimentCalibrationIntakeResult:
valid: bool
errors: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)


def validate(record: PostExperimentCalibrationIntake) -> PostExperimentCalibrationIntakeResult:
errors: List[str] = []
warnings: List[str] = []

if not record.pci_id.startswith(PCI_PREFIX):
errors.append(f"pci_id must start with '{PCI_PREFIX}', got: {record.pci_id!r}")

if not record.pipeline_version.strip():
errors.append("pipeline_version must be a non-empty string")

if not record.batch_id.strip():
errors.append("batch_id must be a non-empty string")

if not _ISO_DATE_RE.match(record.experiment_date):
errors.append(
f"experiment_date must be ISO format YYYY-MM-DD, got: {record.experiment_date!r}"
)

if record.candidates_tested < 1:
errors.append(
f"candidates_tested must be at least 1, got: {record.candidates_tested}"
)

if record.candidates_with_results < 1:
errors.append(
f"candidates_with_results must be at least 1, got: {record.candidates_with_results}"
)
elif record.candidates_with_results > record.candidates_tested:
errors.append(
f"candidates_with_results ({record.candidates_with_results}) "
f"cannot exceed candidates_tested ({record.candidates_tested})"
)

if record.observed_active_count < 0:
errors.append(
f"observed_active_count must be >= 0, got: {record.observed_active_count}"
)
elif (
record.candidates_with_results >= 1
and record.observed_active_count > record.candidates_with_results
):
errors.append(
f"observed_active_count ({record.observed_active_count}) "
f"cannot exceed candidates_with_results ({record.candidates_with_results})"
)

if not (0.0 <= record.prediction_hit_rate <= 1.0):
errors.append(
f"prediction_hit_rate must be in [0, 1], got: {record.prediction_hit_rate}"
)
elif record.candidates_with_results >= 1:
computed = record.observed_active_count / record.candidates_with_results
if abs(computed - record.prediction_hit_rate) > HIT_RATE_TOLERANCE:
errors.append(
f"prediction_hit_rate ({record.prediction_hit_rate:.4f}) is inconsistent with "
f"observed_active_count/candidates_with_results "
f"({record.observed_active_count}/{record.candidates_with_results} = {computed:.4f}); "
f"tolerance is {HIT_RATE_TOLERANCE}"
)

if not record.calibration_update_rationale.strip():
errors.append("calibration_update_rationale must be a non-empty string")
elif len(record.calibration_update_rationale) > RATIONALE_MAX_LENGTH:
errors.append(
f"calibration_update_rationale exceeds {RATIONALE_MAX_LENGTH} chars "
f"(got {len(record.calibration_update_rationale)})"
)

if not record.data_quality_confirmed:
errors.append(
"data_quality_confirmed must be True; "
"do not record a PCI- unless data quality has been verified"
)

if len(record.notes) > NOTES_MAX_LENGTH:
errors.append(
f"notes exceeds {NOTES_MAX_LENGTH} chars (got {len(record.notes)})"
)

# Warnings
if record.candidates_with_results < record.candidates_tested:
missing = record.candidates_tested - record.candidates_with_results
warnings.append(
f"{missing} candidate(s) have no results "
f"({record.candidates_with_results}/{record.candidates_tested} with results); "
"verify missing results are documented as negative or inconclusive"
)

if record.observed_active_count == 0:
warnings.append(
"observed_active_count=0; no active candidates found — "
"verify experimental conditions were correct and result is not a false negative"
)

if (
not record.calibration_update_warranted
and record.prediction_hit_rate < LOW_HIT_RATE_THRESHOLD
):
warnings.append(
f"prediction_hit_rate={record.prediction_hit_rate:.2f} is low "
f"(below {LOW_HIT_RATE_THRESHOLD}) but calibration_update_warranted=False; "
"consider whether calibration update is needed"
)

return PostExperimentCalibrationIntakeResult(
valid=len(errors) == 0, errors=errors, warnings=warnings
)


def validate_dict(data: dict) -> PostExperimentCalibrationIntakeResult:
try:
record = PostExperimentCalibrationIntake(
pci_id=data.get("pci_id", ""),
pipeline_version=data.get("pipeline_version", ""),
batch_id=data.get("batch_id", ""),
experiment_date=data.get("experiment_date", ""),
candidates_tested=int(data.get("candidates_tested", 0)),
candidates_with_results=int(data.get("candidates_with_results", 0)),
predicted_active_count=int(data.get("predicted_active_count", 0)),
observed_active_count=int(data.get("observed_active_count", 0)),
prediction_hit_rate=float(data.get("prediction_hit_rate", 0.0)),
calibration_update_warranted=bool(data.get("calibration_update_warranted", False)),
calibration_update_rationale=data.get("calibration_update_rationale", ""),
data_quality_confirmed=bool(data.get("data_quality_confirmed", False)),
notes=data.get("notes", ""),
)
except Exception as exc:
return PostExperimentCalibrationIntakeResult(
valid=False, errors=[f"Construction error: {exc}"]
)
return validate(record)
Loading
Loading