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 Q Q3 — external sharing clearance — v0.10.9)
> **Last updated:** 2026-07-10 (Phase F F3 — rejection reason entry — v0.10.10)
> **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)
> **New in v0.10.7 (Phase Q Q1):** PilotEvidencePackageEntry — external export bundle (14 fields, CCS+BSP+PSC+PRE+BCM refs, completeness+safety enforcement, 63 tests, BASELINE 4973→5036).
Expand Down
82 changes: 82 additions & 0 deletions docs/evidence/REJECTION_REASON_ENTRY_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Rejection Reason Entry Guide

## Purpose

A `RejectionReasonEntry` (RJR-) records why a specific candidate was rejected
during the selection pipeline. It enforces a controlled vocabulary of rejection
stages and reasons, making failure analysis machine-readable and enabling the
calibration loop to learn from structured failure signals.

## Fields

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `rjr_id` | str | Yes | Unique ID, must start with `RJR-` |
| `pipeline_version` | str | Yes | Version of the pipeline at rejection time |
| `candidate_id` | str | Yes | ID of the rejected candidate |
| `rejection_stage` | str | Yes | Which pipeline stage rejected it (controlled vocabulary) |
| `rejection_reason` | str | Yes | Why it was rejected (controlled vocabulary) |
| `rejection_confidence` | str | Yes | Confidence in the decision: `high`, `medium`, `low`, `uncertain` |
| `rejection_date` | str | Yes | ISO YYYY-MM-DD date of rejection decision |
| `borderline` | bool | Yes | `True` if this was a close call |
| `reviewer` | str | Yes | Name of the human who confirmed the rejection |
| `rejection_notes` | str | Yes | Context notes (max 400 chars); required for `manual_exclusion` |
| `dry_lab_only` | bool | No | Default `True` — dry-lab rejections only |

## Rejection stages

| Stage | Meaning |
|-------|---------|
| `initial_screen` | First-pass activity filter |
| `toxicity_screen` | Cytotoxicity / cell viability screen |
| `hemolysis_screen` | Predicted hemolysis above threshold |
| `novelty_filter` | Too similar to known AMPs |
| `selection_gate` | Failed the calibration readiness gate |
| `safety_policy_block` | Triggered a dual-use or biosafety policy |
| `manual_exclusion` | Human override exclusion |

## Rejection reasons

| Reason | Meaning |
|--------|---------|
| `low_activity_score` | Predicted antimicrobial activity below threshold |
| `hemolysis_risk` | Predicted hemolysis above allowed threshold |
| `toxicity_risk` | Predicted cytotoxicity above threshold |
| `insufficient_novelty` | Sequence too similar to known AMPs or training data |
| `data_quality_failure` | Upstream data quality issue |
| `safety_policy_block` | Safety or dual-use policy triggered |
| `duplicate` | Duplicate of another candidate in the batch |
| `out_of_scope` | Outside the current project scope |
| `manual_exclusion` | Human reviewer override |

## Validation rules

1. `rjr_id` must start with `RJR-`
2. `pipeline_version`, `candidate_id`, `reviewer` must not be empty
3. `rejection_stage` must be in the controlled vocabulary
4. `rejection_reason` must be in the controlled vocabulary
5. `rejection_confidence` must be `high`, `medium`, `low`, or `uncertain`
6. `rejection_date` must be ISO format `YYYY-MM-DD`
7. `rejection_notes` must be at most 400 characters
8. `rejection_reason = manual_exclusion` requires non-empty `rejection_notes`
9. `borderline=True` is inconsistent with `rejection_confidence='high'`

## Warnings

- `rejection_confidence='uncertain'` — candidate may need re-evaluation
- `borderline=True` with empty notes — document the borderline rationale
- `rejection_reason='safety_policy_block'` with no notes — name the policy triggered

## CLI usage

```bash
openamp-foundry rejection-reason-check \
--entry-json '{"rjr_id":"RJR-001","pipeline_version":"v0.10.9","candidate_id":"CAND-001","rejection_stage":"toxicity_screen","rejection_reason":"toxicity_risk","rejection_confidence":"high","rejection_date":"2026-07-10","borderline":false,"reviewer":"Dr. Smith","rejection_notes":"Cytotoxicity above threshold."}' \
--format text
```

## Honest boundaries

- RJR records are dry-lab-only — they record predicted, not confirmed, rejection reasons.
- A `borderline=True` record is not a weakened rejection — it flags cases where the threshold was met but the decision was close, which is useful calibration signal.
- `manual_exclusion` records must always be documented.
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 @@ -111,7 +111,7 @@ Make failure useful.
|---:|---|---|---|
| F1 | Add negative-result entry schema hardening. | Failure records become useful. | B/C |
| F2 | Add negative-result archive validator. | Prevents incomplete records. | B |
| F3 | Add rejection reason taxonomy. | Enables analysis of failed candidates. | 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 |
| F6 | Add examples of non-informative vs informative negative results. | Better interpretation. | A |
Expand Down
43 changes: 43 additions & 0 deletions src/openamp_foundry/cli/commands/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -2717,6 +2717,49 @@ def _run_citation_check(args: argparse.Namespace) -> int:
return 0 if result.passed else 3


def _run_rejection_reason_check(args):
from openamp_foundry.evidence.rejection_reason_entry import (
validate_rejection_reason_dict,
)
import json
import sys

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

if args.format == "json":
out = {
"rjr_id": result.rjr_id,
"candidate_id": result.candidate_id,
"rejection_stage": result.rejection_stage,
"rejection_reason": result.rejection_reason,
"rejection_confidence": result.rejection_confidence,
"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"Rejection Reason: {status}")
print(f" RJR ID: {result.rjr_id}")
print(f" Candidate: {result.candidate_id}")
print(f" Stage: {result.rejection_stage}")
print(f" Reason: {result.rejection_reason}")
print(f" Confidence: {result.rejection_confidence}")
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)


def _run_advisory_review_check(args: argparse.Namespace) -> int:
"""Validate an advisory review entry."""
import json as _json
Expand Down
13 changes: 13 additions & 0 deletions src/openamp_foundry/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
_run_adapter_check, _run_license_check, _run_artifact_compat_check, _run_adoption_scorecard,
_run_reviewer_briefing_check, _run_audit_chain_check, _run_pre_registration_check,
_run_external_sharing_clearance_check,
_run_rejection_reason_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 @@ -2288,6 +2289,15 @@ def build_parser() -> argparse.ArgumentParser:
"--format", choices=["text", "json"], default="text"
)

rejection_reason_parser = sub.add_parser(
"rejection-reason-check",
help="Validate a RejectionReasonEntry (RJR-).",
)
rejection_reason_parser.add_argument("--entry-json", required=True)
rejection_reason_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 @@ -2785,6 +2795,9 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "external-sharing-clearance-check":
return _run_external_sharing_clearance_check(args)

if args.command == "rejection-reason-check":
return _run_rejection_reason_check(args)

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

Expand Down
198 changes: 198 additions & 0 deletions src/openamp_foundry/evidence/rejection_reason_entry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
"""Rejection reason entry schema — Phase F F3.

Records why a candidate was rejected during the selection pipeline with a
controlled vocabulary of stages and reasons. Makes failure analysis
machine-readable and feeds the calibration loop with structured signal
about which screens catch which failure modes.
"""

from __future__ import annotations

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

RJR_PREFIX = "RJR-"

VALID_REJECTION_STAGES = frozenset({
"initial_screen",
"toxicity_screen",
"hemolysis_screen",
"novelty_filter",
"selection_gate",
"safety_policy_block",
"manual_exclusion",
})

VALID_REJECTION_REASONS = frozenset({
"low_activity_score",
"hemolysis_risk",
"toxicity_risk",
"insufficient_novelty",
"data_quality_failure",
"safety_policy_block",
"duplicate",
"out_of_scope",
"manual_exclusion",
})

VALID_REJECTION_CONFIDENCES = frozenset({
"high",
"medium",
"low",
"uncertain",
})

NOTES_MAX_LENGTH = 400


@dataclass
class RejectionReasonEntry:
"""Structured record of why a candidate was rejected."""

rjr_id: str
pipeline_version: str
candidate_id: str
rejection_stage: str # controlled vocabulary
rejection_reason: str # controlled vocabulary
rejection_confidence: str # {high, medium, low, uncertain}
rejection_date: str # ISO YYYY-MM-DD
borderline: bool # True if this was a close call
reviewer: str
rejection_notes: str # max 400 chars
dry_lab_only: bool = True


@dataclass
class RejectionReasonResult:
rjr_id: str
candidate_id: str
rejection_stage: str
rejection_reason: str
rejection_confidence: str
passed: bool
errors: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
dry_lab_only: bool = True


def validate_rejection_reason(
entry: RejectionReasonEntry,
) -> RejectionReasonResult:
errors: List[str] = []
warnings: List[str] = []

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

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

# Rule 3: valid rejection stage
if entry.rejection_stage not in VALID_REJECTION_STAGES:
errors.append(
f"rejection_stage must be one of {sorted(VALID_REJECTION_STAGES)}, "
f"got '{entry.rejection_stage}'"
)

# Rule 4: valid rejection reason
if entry.rejection_reason not in VALID_REJECTION_REASONS:
errors.append(
f"rejection_reason must be one of {sorted(VALID_REJECTION_REASONS)}, "
f"got '{entry.rejection_reason}'"
)

# Rule 5: valid rejection confidence
if entry.rejection_confidence not in VALID_REJECTION_CONFIDENCES:
errors.append(
f"rejection_confidence must be one of {sorted(VALID_REJECTION_CONFIDENCES)}, "
f"got '{entry.rejection_confidence}'"
)

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

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

# Rule 8: manual_exclusion reason requires notes
if (
entry.rejection_reason == "manual_exclusion"
and not entry.rejection_notes.strip()
):
errors.append(
"rejection_notes must not be empty when rejection_reason is 'manual_exclusion'"
" — always document manual exclusions"
)

# Rule 9: borderline=True requires confidence != high
if entry.borderline and entry.rejection_confidence == "high":
errors.append(
"borderline=True is inconsistent with rejection_confidence='high'"
" — borderline cases should have confidence 'medium', 'low', or 'uncertain'"
)

# Warning 1: uncertain confidence
if entry.rejection_confidence == "uncertain":
warnings.append(
"rejection_confidence is 'uncertain' — this candidate may need re-evaluation"
)

# Warning 2: borderline without notes
if entry.borderline and not entry.rejection_notes.strip():
warnings.append(
"borderline=True but rejection_notes is empty — document the borderline rationale"
)

# Warning 3: safety_policy_block without manual review note
if entry.rejection_reason == "safety_policy_block" and not entry.rejection_notes.strip():
warnings.append(
"rejection_reason is 'safety_policy_block' with no rejection_notes"
" — document which policy was triggered"
)

passed = len(errors) == 0
return RejectionReasonResult(
rjr_id=entry.rjr_id,
candidate_id=entry.candidate_id,
rejection_stage=entry.rejection_stage,
rejection_reason=entry.rejection_reason,
rejection_confidence=entry.rejection_confidence,
passed=passed,
errors=errors,
warnings=warnings,
dry_lab_only=entry.dry_lab_only,
)


def validate_rejection_reason_dict(data: dict) -> RejectionReasonResult:
entry = RejectionReasonEntry(
rjr_id=data.get("rjr_id", ""),
pipeline_version=data.get("pipeline_version", ""),
candidate_id=data.get("candidate_id", ""),
rejection_stage=data.get("rejection_stage", ""),
rejection_reason=data.get("rejection_reason", ""),
rejection_confidence=data.get("rejection_confidence", ""),
rejection_date=data.get("rejection_date", ""),
borderline=bool(data.get("borderline", False)),
reviewer=data.get("reviewer", ""),
rejection_notes=data.get("rejection_notes", ""),
dry_lab_only=bool(data.get("dry_lab_only", True)),
)
return validate_rejection_reason(entry)
Loading
Loading