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
3 changes: 2 additions & 1 deletion docs/evidence/METRICS_CURRENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ 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 F F3 — rejection reason entry — v0.10.10)
> **Last updated:** 2026-07-10 (Phase F F2 — negative result archive summary — v0.10.11)
> **New in v0.10.11 (Phase F F2):** NegativeResultArchiveSummary — validates batch of NRR- entries is complete before archiving (11 fields, 9 validation rules, 3 warnings, 63 tests, BASELINE 5225→5288)
> **New in v0.10.10 (Phase F F3):** RejectionReasonEntry — controlled vocabulary for pipeline rejections (11 fields, 9 validation rules, 3 warnings, 63 tests, BASELINE 5162→5225)
> **New in v0.10.9 (Phase Q Q3):** ExternalSharingClearance — auditable release gate before external PEP sharing (12 fields, 9 validation rules, 3 warnings, 63 tests, BASELINE 5099→5162)
> **New in v0.10.8 (Phase Q Q2):** PreRegistrationEntry — pre-experiment plan lock (14 fields, 9 validation rules, 4 warnings, 63 tests, BASELINE 5036→5099)
Expand Down
57 changes: 57 additions & 0 deletions docs/evidence/NEGATIVE_RESULT_ARCHIVE_SUMMARY_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Negative Result Archive Summary Guide

## Purpose

A `NegativeResultArchiveSummary` (NAS-) is an index record that validates a batch
of `NegativeResultRecord` (NRR-) entries is complete and ready for long-term archiving.
It enforces that all negative results from a batch are captured and confirmed before
the batch is marked as archived — preventing silent gaps in the failure record.

## Fields

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `nas_id` | str | Yes | Unique ID, must start with `NAS-` |
| `pipeline_version` | str | Yes | Pipeline version at archive time |
| `batch_id` | str | Yes | Which batch this archive covers |
| `archive_date` | str | Yes | ISO YYYY-MM-DD date of archiving |
| `negative_result_count` | int | Yes | Must equal `len(negative_result_ids)` |
| `negative_result_ids` | List[str] | Yes | All NRR- IDs in this archive |
| `completeness_confirmed` | bool | Yes | Must be `True` — all results captured |
| `all_results_have_reason` | bool | Yes | Must be `True` — every NRR has a reason |
| `archive_notes` | str | Yes | Context notes (max 400 chars) |
| `reviewer` | str | Yes | Human who confirmed archive completeness |
| `dry_lab_only` | bool | No | Default `True` |

## Validation rules

1. `nas_id` must start with `NAS-`
2. `pipeline_version`, `batch_id`, `reviewer` must not be empty
3. `archive_date` must be ISO format `YYYY-MM-DD`
4. `negative_result_count` must be >= 0
5. `len(negative_result_ids)` must equal `negative_result_count`
6. All entries in `negative_result_ids` must start with `NRR-`
7. `completeness_confirmed` must be `True`
8. `all_results_have_reason` must be `True`
9. `archive_notes` must be at most 400 characters

## Warnings

- `negative_result_count = 0` — unusual; confirm all results were tracked
- Empty `archive_notes` — document the archive scope
- Count > 50 — large archives warrant extra curation review

## Honest boundaries

- A NAS record confirms that the archive INDEX is complete, not that all NRR entries
individually pass validation. Always run `openamp-foundry negative-result-check`
on each NRR before creating the NAS.
- `completeness_confirmed=True` is a human attestation, not an automated cross-check.

## CLI usage

```bash
openamp-foundry negative-result-archive-check \
--entry-json '{"nas_id":"NAS-001","pipeline_version":"v0.10.10","batch_id":"BATCH-001","archive_date":"2026-07-10","negative_result_count":2,"negative_result_ids":["NRR-001","NRR-002"],"completeness_confirmed":true,"all_results_have_reason":true,"archive_notes":"Batch 1 archive.","reviewer":"Dr. Smith"}' \
--format text
```
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 @@ -110,7 +110,7 @@ Make failure useful.
| PR | Task | Why it matters | Review class |
|---:|---|---|---|
| F1 | Add negative-result entry schema hardening. | Failure records become useful. | B/C |
| F2 | Add negative-result archive validator. | Prevents incomplete records. | B |
| F2 | Add negative-result archive summary schema (complete). | Prevents incomplete archives: indexes NRR- entries, enforces completeness_confirmed and all_results_have_reason before batch is marked archived. | B |
| F3 | Add rejection reason entry schema (complete). | Enables analysis of failed candidates with controlled vocabulary of stages and reasons; feeds calibration loop with structured failure signal. | B |
| F4 | Add failed-candidate report generator. | Makes failed batches reviewable. | B/C |
| F5 | Add safe-publication filter for negative results. | Supports openness without unsafe release. | D |
Expand Down
39 changes: 39 additions & 0 deletions src/openamp_foundry/cli/commands/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -3869,3 +3869,42 @@ def _run_external_sharing_clearance_check(args):
print(f" - {w}")

sys.exit(0 if result.passed else 1)


def _run_negative_result_archive_check(args):
from openamp_foundry.evidence.negative_result_archive_summary import (
validate_negative_result_archive_summary_dict,
)
import json
import sys

data = json.loads(args.entry_json)
result = validate_negative_result_archive_summary_dict(data)

if args.format == "json":
out = {
"nas_id": result.nas_id,
"batch_id": result.batch_id,
"negative_result_count": result.negative_result_count,
"passed": result.passed,
"errors": result.errors,
"warnings": result.warnings,
"dry_lab_only": result.dry_lab_only,
}
print(json.dumps(out, indent=2))
else:
status = "PASS" if result.passed else "FAIL"
print(f"Negative Result Archive: {status}")
print(f" NAS ID: {result.nas_id}")
print(f" Batch: {result.batch_id}")
print(f" Records: {result.negative_result_count}")
if result.errors:
print(" Errors:")
for e in result.errors:
print(f" - {e}")
if result.warnings:
print(" Warnings:")
for w in result.warnings:
print(f" - {w}")

sys.exit(0 if result.passed else 1)
13 changes: 13 additions & 0 deletions src/openamp_foundry/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
_run_reviewer_briefing_check, _run_audit_chain_check, _run_pre_registration_check,
_run_external_sharing_clearance_check,
_run_rejection_reason_check,
_run_negative_result_archive_check,
_run_hypothesis_outcome_check, _run_baseline_comparison_check, _run_negative_result_check,
_run_experiment_priority_check, _run_calibration_performance_check, _run_prediction_drift_check,
_run_calibration_improvement_check,
Expand Down Expand Up @@ -2298,6 +2299,15 @@ def build_parser() -> argparse.ArgumentParser:
"--format", choices=["text", "json"], default="text"
)

negative_result_archive_parser = sub.add_parser(
"negative-result-archive-check",
help="Validate a NegativeResultArchiveSummary (NAS-).",
)
negative_result_archive_parser.add_argument("--entry-json", required=True)
negative_result_archive_parser.add_argument(
"--format", choices=["text", "json"], default="text"
)

# ── Hypothesis outcome check (Phase N N2) ────────────────────────
hypothesis_outcome_parser = sub.add_parser(
"hypothesis-outcome-check",
Expand Down Expand Up @@ -2798,6 +2808,9 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "rejection-reason-check":
return _run_rejection_reason_check(args)

if args.command == "negative-result-archive-check":
return _run_negative_result_archive_check(args)

if args.command == "hypothesis-outcome-check":
return _run_hypothesis_outcome_check(args)

Expand Down
170 changes: 170 additions & 0 deletions src/openamp_foundry/evidence/negative_result_archive_summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""Negative result archive summary schema — Phase F F2.

An index record that validates a collection of NegativeResultRecord (NRR-)
entries is complete and ready for long-term archiving. Enforces that all
negative results from a batch have been captured before archiving.
"""

from __future__ import annotations

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

NAS_PREFIX = "NAS-"
NRR_PREFIX = "NRR-"

NOTES_MAX_LENGTH = 400
LARGE_ARCHIVE_THRESHOLD = 50


@dataclass
class NegativeResultArchiveSummary:
"""Index record validating a batch of NRR- entries is ready for archiving."""

nas_id: str
pipeline_version: str
batch_id: str
archive_date: str # ISO YYYY-MM-DD
negative_result_count: int # must equal len(negative_result_ids)
negative_result_ids: List[str] # each must start with "NRR-"
completeness_confirmed: bool # must be True
all_results_have_reason: bool # must be True
archive_notes: str # max 400 chars
reviewer: str
dry_lab_only: bool = True


@dataclass
class NegativeResultArchiveSummaryResult:
nas_id: str
batch_id: str
negative_result_count: int
passed: bool
errors: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
dry_lab_only: bool = True


def validate_negative_result_archive_summary(
entry: NegativeResultArchiveSummary,
) -> NegativeResultArchiveSummaryResult:
errors: List[str] = []
warnings: List[str] = []

# Rule 1: NAS- prefix
if not entry.nas_id.startswith(NAS_PREFIX):
errors.append(
f"nas_id must start with '{NAS_PREFIX}', got '{entry.nas_id}'"
)

# Rule 2: non-empty required strings
for fname, val in [
("pipeline_version", entry.pipeline_version),
("batch_id", entry.batch_id),
("reviewer", entry.reviewer),
]:
if not val or not val.strip():
errors.append(f"{fname} must not be empty")

# Rule 3: archive_date ISO YYYY-MM-DD
if not re.match(r"^\d{4}-\d{2}-\d{2}$", entry.archive_date):
errors.append(
f"archive_date must be ISO format YYYY-MM-DD, got '{entry.archive_date}'"
)

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

# Rule 5: negative_result_ids count matches negative_result_count
actual_count = len(entry.negative_result_ids)
if actual_count != entry.negative_result_count:
errors.append(
f"negative_result_count ({entry.negative_result_count}) does not match "
f"len(negative_result_ids) ({actual_count})"
)

# Rule 6: all ids in negative_result_ids start with NRR-
bad_ids = [
nid for nid in entry.negative_result_ids
if not nid.startswith(NRR_PREFIX)
]
if bad_ids:
errors.append(
f"all negative_result_ids must start with '{NRR_PREFIX}', "
f"invalid: {bad_ids[:5]}"
)

# Rule 7: completeness_confirmed must be True
if not entry.completeness_confirmed:
errors.append(
"completeness_confirmed must be True — all negative results from this "
"batch must be captured before archiving"
)

# Rule 8: all_results_have_reason must be True
if not entry.all_results_have_reason:
errors.append(
"all_results_have_reason must be True — every NRR in the archive must "
"document a rejection reason before archiving"
)

# Rule 9: archive_notes length
if len(entry.archive_notes) > NOTES_MAX_LENGTH:
errors.append(
f"archive_notes must be at most {NOTES_MAX_LENGTH} characters, "
f"got {len(entry.archive_notes)}"
)

# Warning 1: empty negative_result_ids
if entry.negative_result_count == 0:
warnings.append(
"negative_result_count is 0 — a batch with no negative results is unusual; "
"confirm all results were tracked"
)

# Warning 2: empty archive_notes
if not entry.archive_notes.strip():
warnings.append(
"archive_notes is empty — consider documenting the scope of this archive"
)

# Warning 3: large archive
if entry.negative_result_count > LARGE_ARCHIVE_THRESHOLD:
warnings.append(
f"negative_result_count ({entry.negative_result_count}) exceeds "
f"{LARGE_ARCHIVE_THRESHOLD} — large archives warrant extra curation review"
)

passed = len(errors) == 0
return NegativeResultArchiveSummaryResult(
nas_id=entry.nas_id,
batch_id=entry.batch_id,
negative_result_count=entry.negative_result_count,
passed=passed,
errors=errors,
warnings=warnings,
dry_lab_only=entry.dry_lab_only,
)


def validate_negative_result_archive_summary_dict(
data: dict,
) -> NegativeResultArchiveSummaryResult:
entry = NegativeResultArchiveSummary(
nas_id=data.get("nas_id", ""),
pipeline_version=data.get("pipeline_version", ""),
batch_id=data.get("batch_id", ""),
archive_date=data.get("archive_date", ""),
negative_result_count=int(data.get("negative_result_count", 0)),
negative_result_ids=list(data.get("negative_result_ids", [])),
completeness_confirmed=bool(data.get("completeness_confirmed", False)),
all_results_have_reason=bool(data.get("all_results_have_reason", False)),
archive_notes=data.get("archive_notes", ""),
reviewer=data.get("reviewer", ""),
dry_lab_only=bool(data.get("dry_lab_only", True)),
)
return validate_negative_result_archive_summary(entry)
Loading
Loading