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
12 changes: 11 additions & 1 deletion docs/evidence/METRICS_CURRENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,17 @@ 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 K K4 — post-experiment calibration intake — v0.10.19)
> **Last updated:** 2026-07-10 (Phase F F9 — negative result dashboard — v0.10.20)

## v0.10.20 — Phase F F9 — Negative Result Dashboard (NRD-)

- **Schema**: `NegativeResultDashboard` — 13 fields, aggregate rejection statistics across a batch
- **Validation**: 13 rules — NRD- prefix, pipeline_version+batch_id non-empty, ISO date, total_candidates_evaluated ≥1, total_rejections in [0,evaluated], rejection_rate in [0,1] and consistent (tol 0.01), top_rejection_stage vocab (7 values), top_rejection_reason vocab (9 values), high_confidence_rejections in [0,total], all_rejections_have_nrr=True enforced, summary non-empty and ≤400 chars, notes ≤300 chars
- **Warnings**: 3 — rejection_rate >0.8, 100% rejection, notes empty
- **Tests**: 63 tests across 9 test classes; BASELINE 5855→5918
- **CLI**: `openamp-foundry negative-result-dashboard-check`
- **Completes NRR→NAS→FCR→NRD chain**: NRD- is the aggregate summary layer above individual failure records

> **New in v0.10.19 — Phase K K4 — Post-Experiment Calibration Intake (PCI-)**

> - **Schema**: `PostExperimentCalibrationIntake` — 13 fields, structured result-to-prediction comparison
Expand Down
92 changes: 92 additions & 0 deletions docs/evidence/NEGATIVE_RESULT_DASHBOARD_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Negative Result Dashboard Guide (NRD-)

## Purpose

A `NegativeResultDashboard` (NRD-) aggregates rejection statistics across a batch to
show failure patterns in structured, machine-readable form. It records the top rejection
stage and reason, the overall rejection rate, high-confidence rejection count, and a
summary narrative.

NRD- is the summary layer above NRR- (individual records), NAS- (archive index), and
FCR- (batch report). While those schemas capture individual failures, NRD- makes the
aggregate pattern visible: which stage is failing most, which reason dominates, and
whether the rejection rate is unusually high.

## Fields

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `nrd_id` | str | Yes | Unique ID, must start with `NRD-` |
| `pipeline_version` | str | Yes | Version of the pipeline evaluated |
| `batch_id` | str | Yes | Batch being summarized |
| `report_date` | str | Yes | ISO date (YYYY-MM-DD) of dashboard generation |
| `total_candidates_evaluated` | int | Yes | Total candidates run through the pipeline (≥1) |
| `total_rejections` | int | Yes | Total candidates rejected (≥0, ≤evaluated) |
| `rejection_rate` | float | Yes | total_rejections/evaluated ∈ [0, 1] (tol 0.01) |
| `top_rejection_stage` | str | Yes | Stage responsible for most rejections |
| `top_rejection_reason` | str | Yes | Most common rejection reason |
| `high_confidence_rejections` | int | Yes | Rejections with high confidence (≥0, ≤total) |
| `all_rejections_have_nrr` | bool | Yes | Must be True; all rejections must have NRR- records |
| `summary` | str | Yes | Narrative summary of batch failures (≤400 chars) |
| `notes` | str | No | Additional context (≤300 chars) |

## Valid Vocabularies

### `top_rejection_stage` (7 values)

`sequence_quality`, `candidate_selection`, `toxicity_screen`, `hemolysis_screen`,
`novelty_check`, `simulation`, `manual_review`

### `top_rejection_reason` (9 values)

`low_score`, `failed_toxicity`, `failed_hemolysis`, `insufficient_novelty`,
`simulation_failure`, `duplicate_sequence`, `low_confidence`, `borderline_unsafe`,
`manual_exclusion`

## Validation Rules

1. `nrd_id` must start with `NRD-`
2. `pipeline_version` must be non-empty
3. `batch_id` must be non-empty
4. `report_date` must match `YYYY-MM-DD`
5. `total_candidates_evaluated` must be ≥1
6. `total_rejections` must be ≥0 and ≤`total_candidates_evaluated`
7. `rejection_rate` must be in [0, 1] and consistent with `total_rejections/total_candidates_evaluated` (tolerance 0.01)
8. `top_rejection_stage` must be in valid set (7 values)
9. `top_rejection_reason` must be in valid set (9 values)
10. `high_confidence_rejections` must be ≥0 and ≤`total_rejections`
11. `all_rejections_have_nrr` must be True
12. `summary` must be non-empty and ≤400 chars
13. `notes` must be ≤300 chars

## Warnings

| Condition | Warning |
|-----------|---------|
| `rejection_rate > 0.8` | Unusually high rejection rate; verify selection pipeline is not too strict |
| `total_rejections == total_candidates_evaluated` | All candidates rejected; verify no systematic pipeline failure |
| `notes` is empty | No reviewer context for this batch's failures |

## Boundaries

- **NRD- is a dashboard, not a root record.** It summarizes NRR- records; NRR- is the ground truth.
- **`all_rejections_have_nrr` must be True.** Do not create a NRD- unless every rejection has a corresponding NRR- record.
- **`top_rejection_stage` and `top_rejection_reason` are the modal values.** They name the most common stage and reason, not all stages and reasons.
- **NRD- is dry-lab only.** It records computational and manual filter decisions, not wet-lab results.

## CLI

```bash
openamp-foundry negative-result-dashboard-check '{"nrd_id": "NRD-001", ...}'
```

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

## Relationship to Other Schemas

```
NRR- ──→ NRD- (dashboard aggregates individual failure records)
NAS- ──→ NRD- (archive index confirms all records are captured)
FCR- ──→ NRD- (batch report feeds aggregate view)
NRD- ──→ CIR- (rejection patterns inform calibration updates)
```
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 @@ -117,7 +117,7 @@ Make failure useful.
| F6 | Add examples of non-informative vs informative negative results. | Better interpretation. | A |
| F7 | Add calibration link from negative-result entries to intake reports. | Closes learning loop. | C |
| F8 | Add benchmark for whether rejected candidates resemble known failure modes. | Improves rejection logic. | C |
| F9 | Add negative-result dashboard from toy data. | Shows value of failures. | B |
| F9 | Add negative-result dashboard schema (complete). | NRD- aggregates NRR- rejection statistics: rejection rate consistency check, all_rejections_have_nrr enforced, top stage/reason controlled vocabulary, 100% rejection warning. | B |
| F10 | Add policy that public claims must mention relevant negative results. | Prevents cherry-picking. | D |

## Phase G — Calibration and active-learning rigor
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 @@ -2992,3 +2992,25 @@ def _run_post_experiment_calibration_intake_check(args):
for w in result.warnings:
print(f"WARNING: {w}")
sys.exit(1)


def _run_negative_result_dashboard_check(args):
import json, sys
from openamp_foundry.evidence.negative_result_dashboard 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 @@ -55,6 +55,7 @@
from openamp_foundry.evidence.batch_experiment_priority_ranker import validate_dict as validate_batch_experiment_priority_ranker_dict
from openamp_foundry.evidence.calibration_improvement_record import validate_dict as validate_calibration_improvement_record_dict
from openamp_foundry.evidence.candidate_selection_rationale import validate_dict as validate_candidate_selection_rationale_dict
from openamp_foundry.evidence.negative_result_dashboard import validate_dict as validate_negative_result_dashboard_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
Expand Down Expand Up @@ -2521,6 +2522,12 @@ def build_parser() -> argparse.ArgumentParser:
)
pci_parser.add_argument("json_input", help="JSON string of the PCI- record")

nrd_parser = subparsers.add_parser(
"negative-result-dashboard-check",
help="Validate a NegativeResultDashboard (NRD-) record",
)
nrd_parser.add_argument("json_input", help="JSON string of the NRD- record")

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

elif args.command == "negative-result-dashboard-check":
from openamp_foundry.cli.commands.reports import _run_negative_result_dashboard_check
_run_negative_result_dashboard_check(args)

else:
parser.error("unknown command")
return 2
Expand Down
203 changes: 203 additions & 0 deletions src/openamp_foundry/evidence/negative_result_dashboard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
"""Negative result dashboard schema — Phase F F9.

Aggregates rejection statistics across a batch to show failure patterns.
Records top rejection stage and reason, high-confidence rejection count,
and enforces that all rejections have NRR- records.
"""

from __future__ import annotations

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

NRD_PREFIX = "NRD-"

VALID_REJECTION_STAGES = frozenset({
"sequence_quality",
"candidate_selection",
"toxicity_screen",
"hemolysis_screen",
"novelty_check",
"simulation",
"manual_review",
})

VALID_REJECTION_REASONS = frozenset({
"low_score",
"failed_toxicity",
"failed_hemolysis",
"insufficient_novelty",
"simulation_failure",
"duplicate_sequence",
"low_confidence",
"borderline_unsafe",
"manual_exclusion",
})

REJECTION_RATE_TOLERANCE = 0.01
HIGH_REJECTION_RATE_WARNING = 0.8
SUMMARY_MAX_LENGTH = 400
NOTES_MAX_LENGTH = 300
_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")


@dataclass
class NegativeResultDashboard:
nrd_id: str
pipeline_version: str
batch_id: str
report_date: str
total_candidates_evaluated: int
total_rejections: int
rejection_rate: float
top_rejection_stage: str
top_rejection_reason: str
high_confidence_rejections: int
all_rejections_have_nrr: bool
summary: str
notes: str = ""


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


def validate(dashboard: NegativeResultDashboard) -> NegativeResultDashboardResult:
errors: List[str] = []
warnings: List[str] = []

if not dashboard.nrd_id.startswith(NRD_PREFIX):
errors.append(f"nrd_id must start with '{NRD_PREFIX}', got: {dashboard.nrd_id!r}")

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

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

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

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

if dashboard.total_rejections < 0:
errors.append(
f"total_rejections must be >= 0, got: {dashboard.total_rejections}"
)
elif dashboard.total_rejections > dashboard.total_candidates_evaluated:
errors.append(
f"total_rejections ({dashboard.total_rejections}) cannot exceed "
f"total_candidates_evaluated ({dashboard.total_candidates_evaluated})"
)

if not (0.0 <= dashboard.rejection_rate <= 1.0):
errors.append(
f"rejection_rate must be in [0, 1], got: {dashboard.rejection_rate}"
)
elif dashboard.total_candidates_evaluated >= 1:
computed = dashboard.total_rejections / dashboard.total_candidates_evaluated
if abs(computed - dashboard.rejection_rate) > REJECTION_RATE_TOLERANCE:
errors.append(
f"rejection_rate ({dashboard.rejection_rate:.4f}) is inconsistent with "
f"total_rejections/total_candidates_evaluated "
f"({dashboard.total_rejections}/{dashboard.total_candidates_evaluated} = {computed:.4f}); "
f"tolerance is {REJECTION_RATE_TOLERANCE}"
)

if dashboard.top_rejection_stage not in VALID_REJECTION_STAGES:
errors.append(
f"top_rejection_stage {dashboard.top_rejection_stage!r} not in valid set: "
f"{sorted(VALID_REJECTION_STAGES)}"
)

if dashboard.top_rejection_reason not in VALID_REJECTION_REASONS:
errors.append(
f"top_rejection_reason {dashboard.top_rejection_reason!r} not in valid set: "
f"{sorted(VALID_REJECTION_REASONS)}"
)

if dashboard.high_confidence_rejections < 0:
errors.append(
f"high_confidence_rejections must be >= 0, got: {dashboard.high_confidence_rejections}"
)
elif dashboard.high_confidence_rejections > dashboard.total_rejections:
errors.append(
f"high_confidence_rejections ({dashboard.high_confidence_rejections}) "
f"cannot exceed total_rejections ({dashboard.total_rejections})"
)

if not dashboard.all_rejections_have_nrr:
errors.append(
"all_rejections_have_nrr must be True; "
"every rejection must have a corresponding NRR- record before a dashboard is created"
)

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

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

# Warnings
if dashboard.rejection_rate > HIGH_REJECTION_RATE_WARNING:
warnings.append(
f"rejection_rate={dashboard.rejection_rate:.2f} is unusually high "
f"(>{HIGH_REJECTION_RATE_WARNING}); verify the selection pipeline is not too strict"
)

if (
dashboard.total_candidates_evaluated >= 1
and dashboard.total_rejections == dashboard.total_candidates_evaluated
):
warnings.append(
"total_rejections equals total_candidates_evaluated; "
"all candidates rejected — verify no systematic pipeline failure"
)

if not dashboard.notes.strip():
warnings.append(
"notes is empty; consider documenting the batch failure context"
)

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


def validate_dict(data: dict) -> NegativeResultDashboardResult:
try:
dashboard = NegativeResultDashboard(
nrd_id=data.get("nrd_id", ""),
pipeline_version=data.get("pipeline_version", ""),
batch_id=data.get("batch_id", ""),
report_date=data.get("report_date", ""),
total_candidates_evaluated=int(data.get("total_candidates_evaluated", 0)),
total_rejections=int(data.get("total_rejections", 0)),
rejection_rate=float(data.get("rejection_rate", 0.0)),
top_rejection_stage=data.get("top_rejection_stage", ""),
top_rejection_reason=data.get("top_rejection_reason", ""),
high_confidence_rejections=int(data.get("high_confidence_rejections", 0)),
all_rejections_have_nrr=bool(data.get("all_rejections_have_nrr", False)),
summary=data.get("summary", ""),
notes=data.get("notes", ""),
)
except Exception as exc:
return NegativeResultDashboardResult(
valid=False, errors=[f"Construction error: {exc}"]
)
return validate(dashboard)
Loading
Loading