// ScoredRun — output of the deterministic Success Scorer (Phase 3). // Spec mandates 4 categories with explicit reasons; we add scorer // versioning so a future scorer change is detectable in historical data. import { ValidationResult, requireString, requireIsoTimestamp, requireProvenance, requireStringArray, requireNumber, } from "./types"; export const SCORED_RUN_SCHEMA_VERSION = 1; export const SCORE_CATEGORIES = ["accepted", "partially_accepted", "rejected", "needs_human_review"] as const; export type ScoreCategory = (typeof SCORE_CATEGORIES)[number]; export interface ScoredRun { schema_version: number; evidence_run_id: string; // FK to EvidenceRecord.run_id evidence_task_id: string; // FK to EvidenceRecord.task_id category: ScoreCategory; reasons: string[]; // human-readable, e.g. ["cargo_green=true", "anchor_grounding<0.7"] scored_at: string; // ISO 8601 scorer_version: string; // e.g. "v1.0.0" — bumped on scorer code change // Sub-scores that the scorer collapsed into the category. Persisted // so a downstream UI can show "why" without re-running the scorer. sub_scores?: { cargo_green?: boolean; anchor_grounding?: number; schema_valid?: boolean; pathway_replay_succeeded?: boolean; observer_verdict?: "accept" | "reject" | "cycle"; [key: string]: unknown; }; provenance: { source_file: string; line_offset?: number; sig_hash: string; recorded_at: string; }; } export function validateScoredRun(input: unknown): ValidationResult { const errors: string[] = []; if (typeof input !== "object" || input === null) { return { valid: false, errors: ["expected object"] }; } const r = input as Record; let ok = true; if (r.schema_version !== SCORED_RUN_SCHEMA_VERSION) { errors.push(`schema_version: expected ${SCORED_RUN_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`); ok = false; } ok = requireString(r.evidence_run_id, "evidence_run_id", errors) && ok; ok = requireString(r.evidence_task_id, "evidence_task_id", errors) && ok; ok = requireIsoTimestamp(r.scored_at, "scored_at", errors) && ok; ok = requireString(r.scorer_version, "scorer_version", errors) && ok; ok = requireStringArray(r.reasons, "reasons", errors) && ok; if (Array.isArray(r.reasons) && r.reasons.length === 0) { errors.push("reasons: must be non-empty (every score must have at least one reason)"); ok = false; } if (!SCORE_CATEGORIES.includes(r.category as ScoreCategory)) { errors.push(`category: must be one of ${SCORE_CATEGORIES.join("|")}, got ${JSON.stringify(r.category)}`); ok = false; } ok = requireProvenance(r.provenance, "provenance", errors) && ok; if (r.sub_scores !== undefined) { if (typeof r.sub_scores !== "object" || r.sub_scores === null) { errors.push("sub_scores: expected object when present"); ok = false; } else { const ss = r.sub_scores as Record; if (ss.anchor_grounding !== undefined) { if (!requireNumber(ss.anchor_grounding, "sub_scores.anchor_grounding", errors)) ok = false; else if ((ss.anchor_grounding as number) < 0 || (ss.anchor_grounding as number) > 1) { errors.push("sub_scores.anchor_grounding: must be in [0, 1]"); ok = false; } } if (ss.observer_verdict !== undefined && !["accept", "reject", "cycle"].includes(ss.observer_verdict as string)) { errors.push("sub_scores.observer_verdict: must be accept|reject|cycle"); ok = false; } } } if (!ok) return { valid: false, errors }; return { valid: true, value: r as unknown as ScoredRun }; }