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
93 changes: 93 additions & 0 deletions docs/evidence/CANDIDATE_SELECTION_RATIONALE_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Candidate Selection Rationale Guide (CSR-)

## Purpose

A `CandidateSelectionRationale` (CSR-) records why specific candidates were selected
for a batch. It documents the selection strategy, ranking method, and selection
rationale, and enforces that the calibration gate was passed before selection was
authorized. This makes selection decisions auditable, reproducible, and usable as
calibration signal in future cycles.

## Fields

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `csr_id` | str | Yes | Unique ID, must start with `CSR-` |
| `pipeline_version` | str | Yes | Version of the pipeline that made the selection |
| `batch_id` | str | Yes | Identifier of the batch being selected |
| `bsp_id` | str | Yes | Batch selection proposal (must start with `BSP-`) |
| `selection_date` | str | Yes | ISO date (YYYY-MM-DD) of selection decision |
| `selection_strategy` | str | Yes | Controlled vocabulary: exploit/explore/diversity/mixed |
| `candidate_count` | int | Yes | Number of candidates selected (≥1) |
| `candidate_ids` | List[str] | Yes | Identifiers of selected candidates |
| `ranking_method` | str | Yes | How candidates were ranked/selected |
| `calibration_gate_passed` | bool | Yes | Must be True; gate must be passed before selection |
| `selection_rationale` | str | Yes | Human-readable rationale (≤400 chars) |
| `notes` | str | No | Additional context (≤300 chars) |

## Valid Vocabularies

### `selection_strategy`

| Value | Description |
|-------|-------------|
| `exploit` | Focus on highest-scoring candidates from current model |
| `explore` | Deliberately sample from lower-confidence regions |
| `diversity` | Maximize structural or sequence diversity |
| `mixed` | Combination of exploit and explore or diversity |

### `ranking_method`

| Value | Description |
|-------|-------------|
| `composite_score` | Ranked by composite score from multiple predictors |
| `diversity_filter` | Selected after diversity filtering |
| `novelty_weighted` | Score weighted by novelty vs known sequences |
| `expert_review` | Final selection by domain expert |
| `random_balanced` | Random sampling within strata |
| `calibration_guided` | Selection guided by calibration uncertainty |

## Validation Rules

1. `csr_id` must start with `CSR-`
2. `pipeline_version` must be non-empty
3. `batch_id` must be non-empty
4. `bsp_id` must start with `BSP-`
5. `selection_date` must match `YYYY-MM-DD`
6. `selection_strategy` must be in valid set (4 values)
7. `candidate_count` must be ≥1
8. `len(candidate_ids)` must equal `candidate_count`
9. `ranking_method` must be in valid set (6 values)
10. `calibration_gate_passed` must be True
11. `selection_rationale` must be non-empty and ≤400 chars
12. `notes` must be ≤300 chars

## Warnings

| Condition | Warning |
|-----------|---------|
| `candidate_count > 20` | Unusually large batch; verify intent |
| `ranking_method='random_balanced'` and `notes=''` | Random selection should be justified |
| `ranking_method='expert_review'` and `notes=''` | Expert review decisions should be documented |

## Boundaries

- **CSR- records computational selection, not biological prediction.** Selecting a candidate does not predict its activity.
- **`calibration_gate_passed` must be True.** The pipeline cannot select candidates without passing calibration. This is a hard enforcement of the calibration→selection dependency.
- **CSR- is retrospective.** It documents decisions already made; it does not authorize them.

## CLI

```bash
openamp-foundry candidate-selection-rationale-check '{"csr_id": "CSR-001", ...}'
```

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

## Relationship to Other Schemas

```
BSP- ──→ CSR- (batch selection proposal authorizes the selection)
CSR- ──→ PPC- (selection rationale is a component of pilot completeness)
CSR- ──→ PRE- (pre-registration records the candidates selected here)
```
1 change: 1 addition & 0 deletions docs/evidence/METRICS_CURRENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Machine-readable snapshot: `outputs/metrics_snapshot.json` regenerated with `mak
> with this file, this file wins. Updated whenever benchmark/benchmark config changes.
>
> **Last updated:** 2026-07-10 (Phase K K3 — pilot package completeness report — v0.10.15)
> **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.
> **New in v0.10.15 (Phase K K3):** PilotPackageCompletenessReport — PPC- completeness gate: confirms CCS-+BSP-+PSC-+PRE-+BCM- all present and ESC- cleared before external sharing (12 fields, 12 validation rules, 3 warnings, 63 tests, BASELINE 5540→5603)
> **New in v0.10.14 (Phase F F1):** NegativeResultEntry — atomic NRR- failure record with stage/reason/confidence vocabulary, foundation for NAS- and FCR- chain (12 fields, 10 validation rules, 4 warnings, 63 tests, BASELINE 5477→5540)
> **New in v0.10.14 (Phase E E9):** DomainReviewOutcome — controlled taxonomy expert verdict on a PEP (11 fields, 9 validation rules, 3 warnings, 63 tests, BASELINE 5414→5477); closes ESC→RVQ→DRO review chain
Expand Down
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 @@ -194,7 +194,7 @@ Build the evidence and accountability artifacts needed for credible external val

| PR | Task | Why it matters | Review class |
|---:|---|---|---|
| K1 | Add candidate selection rationale schema. | Auditable selection decisions. | B |
| 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. | Rank candidates for next synthesis wave. | 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 |
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 @@ -2904,3 +2904,25 @@ def _run_negative_result_entry_check(args):
for w in result.warnings:
print(f"WARNING: {w}")
sys.exit(1)


def _run_candidate_selection_rationale_check(args):
import json, sys
from openamp_foundry.evidence.candidate_selection_rationale 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 @@ -52,6 +52,7 @@
import json
from pathlib import Path

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.quality import assess_certificate_quality
Expand Down Expand Up @@ -2493,6 +2494,12 @@ def build_parser() -> argparse.ArgumentParser:
)
ppc_parser.add_argument("json_input", help="JSON string of the PPC- record")

csr_parser = sub.add_parser(
"candidate-selection-rationale-check",
help="Validate a CandidateSelectionRationale (CSR-) record",
)
csr_parser.add_argument("json_input", help="JSON string of the CSR- record")

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

elif args.command == "candidate-selection-rationale-check":
from openamp_foundry.cli.commands.reports import _run_candidate_selection_rationale_check
_run_candidate_selection_rationale_check(args)

else:
parser.error("unknown command")
return 2
Expand Down
169 changes: 169 additions & 0 deletions src/openamp_foundry/evidence/candidate_selection_rationale.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""Candidate selection rationale schema — Phase K K1.

Records why specific candidates were selected for a batch.
Documents selection strategy, ranking method, and calibration gate status,
making selection decisions auditable and comparable across batches.
"""

from __future__ import annotations

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

CSR_PREFIX = "CSR-"
BSP_PREFIX = "BSP-"

VALID_SELECTION_STRATEGIES = frozenset({
"exploit",
"explore",
"diversity",
"mixed",
})

VALID_RANKING_METHODS = frozenset({
"composite_score",
"diversity_filter",
"novelty_weighted",
"expert_review",
"random_balanced",
"calibration_guided",
})

RATIONALE_MAX_LENGTH = 400
NOTES_MAX_LENGTH = 300
MAX_CANDIDATE_COUNT_WARNING = 20
_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")


@dataclass
class CandidateSelectionRationale:
csr_id: str
pipeline_version: str
batch_id: str
bsp_id: str
selection_date: str
selection_strategy: str
candidate_count: int
candidate_ids: List[str]
ranking_method: str
calibration_gate_passed: bool
selection_rationale: str
notes: str = ""


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


def validate(entry: CandidateSelectionRationale) -> CandidateSelectionRationaleResult:
errors: List[str] = []
warnings: List[str] = []

if not entry.csr_id.startswith(CSR_PREFIX):
errors.append(f"csr_id must start with '{CSR_PREFIX}', got: {entry.csr_id!r}")

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

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

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

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

if entry.selection_strategy not in VALID_SELECTION_STRATEGIES:
errors.append(
f"selection_strategy {entry.selection_strategy!r} not in valid set: "
f"{sorted(VALID_SELECTION_STRATEGIES)}"
)

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

if len(entry.candidate_ids) != entry.candidate_count:
errors.append(
f"candidate_ids length ({len(entry.candidate_ids)}) does not match "
f"candidate_count ({entry.candidate_count})"
)

if entry.ranking_method not in VALID_RANKING_METHODS:
errors.append(
f"ranking_method {entry.ranking_method!r} not in valid set: "
f"{sorted(VALID_RANKING_METHODS)}"
)

if not entry.calibration_gate_passed:
errors.append(
"calibration_gate_passed must be True; "
"candidates cannot be selected without passing the calibration gate"
)

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

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

# Warnings
if entry.candidate_count > MAX_CANDIDATE_COUNT_WARNING:
warnings.append(
f"candidate_count={entry.candidate_count} is unusually large (>{MAX_CANDIDATE_COUNT_WARNING}); "
"verify this batch size is intentional"
)

if entry.ranking_method == "random_balanced" and not entry.notes.strip():
warnings.append(
"ranking_method='random_balanced' without notes; "
"random selection should be justified in notes"
)

if entry.ranking_method == "expert_review" and not entry.notes.strip():
warnings.append(
"ranking_method='expert_review' without notes; "
"expert review decisions should be documented"
)

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


def validate_dict(data: dict) -> CandidateSelectionRationaleResult:
try:
entry = CandidateSelectionRationale(
csr_id=data.get("csr_id", ""),
pipeline_version=data.get("pipeline_version", ""),
batch_id=data.get("batch_id", ""),
bsp_id=data.get("bsp_id", ""),
selection_date=data.get("selection_date", ""),
selection_strategy=data.get("selection_strategy", ""),
candidate_count=int(data.get("candidate_count", 0)),
candidate_ids=list(data.get("candidate_ids", [])),
ranking_method=data.get("ranking_method", ""),
calibration_gate_passed=bool(data.get("calibration_gate_passed", False)),
selection_rationale=data.get("selection_rationale", ""),
notes=data.get("notes", ""),
)
except Exception as exc:
return CandidateSelectionRationaleResult(
valid=False, errors=[f"Construction error: {exc}"]
)
return validate(entry)
Loading
Loading