Skip to content

Commit 7e3799c

Browse files
cschanhniemOpenCode
andauthored
feat: Phase T T4 selection audit trail (SAT-) -- immutable ordered log of selection/rejection decisions; VALID_DECISIONS (3: selected/rejected/deferred); VALID_STAGES (4: nomination/screening/ranking/shortlisting); final_shortlist_ids auto-computed (sorted, unique selected); evidence_artifact_ids per entry; build() auto-computes counts; dry_lab_only=True; 55 tests (#985)
Co-authored-by: OpenCode <opencode@example.com>
1 parent 53b025d commit 7e3799c

3 files changed

Lines changed: 862 additions & 1 deletion

File tree

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
from __future__ import annotations
2+
from dataclasses import dataclass, field
3+
4+
5+
VALID_DECISIONS = frozenset({"selected", "rejected", "deferred"})
6+
7+
VALID_STAGES = frozenset({"nomination", "screening", "ranking", "shortlisting"})
8+
9+
10+
@dataclass
11+
class DecisionEntry:
12+
candidate_family_id: str
13+
decision: str
14+
decision_stage: str
15+
rationale: str
16+
evidence_artifact_ids: list[str]
17+
decided_at: str
18+
19+
20+
@dataclass
21+
class SelectionAuditTrail:
22+
sat_id: str
23+
batch_id: str
24+
pipeline_version: str
25+
n_entries: int
26+
entries: list[DecisionEntry]
27+
n_selected: int
28+
n_rejected: int
29+
n_deferred: int
30+
final_shortlist_ids: list[str]
31+
dry_lab_only: bool
32+
limitations: list[str]
33+
created_at: str
34+
35+
36+
@dataclass
37+
class SATValidationResult:
38+
valid: bool
39+
violations: list[str] = field(default_factory=list)
40+
41+
42+
def validate_selection_audit_trail(trail: SelectionAuditTrail) -> SATValidationResult:
43+
violations = []
44+
45+
if not trail.sat_id.startswith("SAT-"):
46+
violations.append("sat_id must start with 'SAT-'")
47+
48+
if not trail.dry_lab_only:
49+
violations.append("dry_lab_only must be True")
50+
51+
if trail.n_entries != len(trail.entries):
52+
violations.append(
53+
f"n_entries ({trail.n_entries}) must equal len(entries) ({len(trail.entries)})"
54+
)
55+
56+
expected_selected = sum(1 for e in trail.entries if e.decision == "selected")
57+
if trail.n_selected != expected_selected:
58+
violations.append(
59+
f"n_selected ({trail.n_selected}) must equal count of selected entries ({expected_selected})"
60+
)
61+
62+
expected_rejected = sum(1 for e in trail.entries if e.decision == "rejected")
63+
if trail.n_rejected != expected_rejected:
64+
violations.append(
65+
f"n_rejected ({trail.n_rejected}) must equal count of rejected entries ({expected_rejected})"
66+
)
67+
68+
expected_deferred = sum(1 for e in trail.entries if e.decision == "deferred")
69+
if trail.n_deferred != expected_deferred:
70+
violations.append(
71+
f"n_deferred ({trail.n_deferred}) must equal count of deferred entries ({expected_deferred})"
72+
)
73+
74+
expected_shortlist = sorted({e.candidate_family_id for e in trail.entries if e.decision == "selected"})
75+
if trail.final_shortlist_ids != expected_shortlist:
76+
violations.append(
77+
f"final_shortlist_ids {trail.final_shortlist_ids} must equal sorted selected family IDs {expected_shortlist}"
78+
)
79+
80+
for entry in trail.entries:
81+
if entry.candidate_family_id.startswith("TOY-"):
82+
violations.append(f"TOY- candidate_family_id ('{entry.candidate_family_id}') is not allowed")
83+
84+
if entry.decision not in VALID_DECISIONS:
85+
violations.append(
86+
f"decision '{entry.decision}' must be one of {sorted(VALID_DECISIONS)} for {entry.candidate_family_id}"
87+
)
88+
89+
if entry.decision_stage not in VALID_STAGES:
90+
violations.append(
91+
f"decision_stage '{entry.decision_stage}' must be one of {sorted(VALID_STAGES)} for {entry.candidate_family_id}"
92+
)
93+
94+
if not entry.rationale:
95+
violations.append(f"rationale must be non-empty for {entry.candidate_family_id}")
96+
97+
if entry.evidence_artifact_ids != sorted(entry.evidence_artifact_ids):
98+
violations.append(
99+
f"evidence_artifact_ids must be sorted for {entry.candidate_family_id}"
100+
)
101+
102+
if not trail.limitations:
103+
violations.append("limitations must be non-empty")
104+
105+
return SATValidationResult(valid=len(violations) == 0, violations=violations)
106+
107+
108+
def build_selection_audit_trail(
109+
sat_id: str,
110+
batch_id: str,
111+
pipeline_version: str,
112+
decision_dicts: list[dict],
113+
limitations: list[str],
114+
created_at: str,
115+
) -> SelectionAuditTrail:
116+
entries: list[DecisionEntry] = []
117+
for d in decision_dicts:
118+
entries.append(
119+
DecisionEntry(
120+
candidate_family_id=d["candidate_family_id"],
121+
decision=d["decision"],
122+
decision_stage=d["decision_stage"],
123+
rationale=d["rationale"],
124+
evidence_artifact_ids=sorted(d.get("evidence_artifact_ids", [])),
125+
decided_at=d.get("decided_at", ""),
126+
)
127+
)
128+
129+
n_entries = len(entries)
130+
n_selected = sum(1 for e in entries if e.decision == "selected")
131+
n_rejected = sum(1 for e in entries if e.decision == "rejected")
132+
n_deferred = sum(1 for e in entries if e.decision == "deferred")
133+
final_shortlist_ids = sorted({e.candidate_family_id for e in entries if e.decision == "selected"})
134+
135+
return SelectionAuditTrail(
136+
sat_id=sat_id,
137+
batch_id=batch_id,
138+
pipeline_version=pipeline_version,
139+
n_entries=n_entries,
140+
entries=entries,
141+
n_selected=n_selected,
142+
n_rejected=n_rejected,
143+
n_deferred=n_deferred,
144+
final_shortlist_ids=final_shortlist_ids,
145+
dry_lab_only=True,
146+
limitations=limitations,
147+
created_at=created_at,
148+
)
149+
150+
151+
def format_selection_audit_trail(trail: SelectionAuditTrail) -> str:
152+
lines = [
153+
f"Selection Audit Trail — {trail.sat_id}",
154+
f"Batch: {trail.batch_id} | Pipeline: {trail.pipeline_version}",
155+
f"Entries: {trail.n_entries} | Selected: {trail.n_selected} | "
156+
f"Rejected: {trail.n_rejected} | Deferred: {trail.n_deferred}",
157+
f"Final Shortlist ({len(trail.final_shortlist_ids)}): {', '.join(trail.final_shortlist_ids) or '(none)'}",
158+
f"Created: {trail.created_at}",
159+
f"Limitations: {'; '.join(trail.limitations)}",
160+
f"dry_lab_only: {trail.dry_lab_only}",
161+
]
162+
return "\n".join(lines)

0 commit comments

Comments
 (0)