|
| 1 | +// Maintainer action-space schema + proposal validator (#9260, harness #9216, epic #8534). |
| 2 | +// |
| 3 | +// The frozen-repo benchmark's task format: a candidate agent looks at a repo frozen at commit T and proposes |
| 4 | +// the action it expects the maintainer to take on each work unit within the task's horizon. Without a CLOSED, |
| 5 | +// validated action space, two agents' outputs are not comparable and the scorer has nothing stable to score |
| 6 | +// against — so the action set here is closed by construction (an unrecognized action is a validation ERROR, |
| 7 | +// never a silently-ignored or zero-scored entry), per-action parameters are typed (parameters that do not |
| 8 | +// apply to an action are rejected as unknown keys), and abstention is FIRST-CLASS: declining to predict a |
| 9 | +// work unit is recorded and feeds the coverage metric in #9215's scoring semantics, so a precise-but-narrow |
| 10 | +// agent stays distinguishable from a broad-but-noisy one. |
| 11 | +// |
| 12 | +// The prediction horizon is deliberately NOT a proposal field: it is part of the TASK (`BenchmarkTask`, |
| 13 | +// fixed per benchmark), so an agent cannot pick a horizon that flatters its answer. A proposal echoes the |
| 14 | +// task's identity (`benchmarkId` + `snapshotRef` + `workUnitId`) so a submission cannot be replayed against |
| 15 | +// a different snapshot or benchmark. |
| 16 | +// |
| 17 | +// Same purity contract and validator shape as `validateAttestationEnvelope` in ./attestation-envelope.ts — |
| 18 | +// pure, never throws for ordinarily-invalid input, one error per failing field path, unknown keys rejected |
| 19 | +// rather than ignored. Public (engine barrel) so a candidate agent author validates locally before |
| 20 | +// submitting, against the exact code the harness runs. |
| 21 | + |
| 22 | +/** The closed maintainer action set. Closed, not extensible-by-string: the scorer's ground-truth classes |
| 23 | + * (#9261's realized-history outcomes) are exactly these, so an open set would let an agent emit classes the |
| 24 | + * scorer cannot compare. */ |
| 25 | +export type BenchmarkActionKind = "merge" | "close" | "request_changes" | "label" | "hold"; |
| 26 | + |
| 27 | +/** Why a close is predicted — the class, not free text, because realized history grades the CLASS. Aligned |
| 28 | + * with the reason families the gate itself distinguishes; a benchmark bump to this list is a schemaVersion |
| 29 | + * bump, never an in-place widening. */ |
| 30 | +export type BenchmarkCloseReasonClass = "defective" | "duplicate" | "spam" | "stale" | "out_of_scope"; |
| 31 | + |
| 32 | +/** One proposed maintainer action with its typed, per-action parameters. `merge` and `hold` carry none. */ |
| 33 | +export type BenchmarkAction = |
| 34 | + | { kind: "merge" } |
| 35 | + | { kind: "hold" } |
| 36 | + | { kind: "close"; reasonClass: BenchmarkCloseReasonClass } |
| 37 | + | { kind: "request_changes"; blockingConcern: string } |
| 38 | + | { kind: "label"; labels: string[] }; |
| 39 | + |
| 40 | +/** An agent's answer for ONE work unit: either a concrete action, or a first-class abstention. */ |
| 41 | +export type BenchmarkPrediction = { kind: "abstain" } | { kind: "act"; action: BenchmarkAction }; |
| 42 | + |
| 43 | +/** The task side of the contract: what the harness publishes for agents to answer. The horizon lives HERE — |
| 44 | + * "the action the maintainer takes within `horizonDays` of the snapshot's frozen instant" — never on the |
| 45 | + * proposal, so every agent answers the same question. */ |
| 46 | +export type BenchmarkTask = { |
| 47 | + schemaVersion: 1; |
| 48 | + benchmarkId: string; |
| 49 | + /** Content-addressed reference to the frozen snapshot (#9259's builder output) — 64 lowercase hex. */ |
| 50 | + snapshotRef: string; |
| 51 | + /** The work unit inside the snapshot, e.g. "owner/repo#123". */ |
| 52 | + workUnitId: string; |
| 53 | + /** Fixed per benchmark; a proposal is scored against what the maintainer actually did within this many |
| 54 | + * days of the frozen instant (#9261's reversal-aware outcome rules). */ |
| 55 | + horizonDays: number; |
| 56 | + /** The snapshot's frozen instant, ISO-8601 — the horizon's t=0. */ |
| 57 | + frozenAt: string; |
| 58 | +}; |
| 59 | + |
| 60 | +/** One agent's proposal for one work unit. Echoes the task identity so it cannot be replayed elsewhere. */ |
| 61 | +export type BenchmarkProposal = { |
| 62 | + schemaVersion: 1; |
| 63 | + benchmarkId: string; |
| 64 | + snapshotRef: string; |
| 65 | + workUnitId: string; |
| 66 | + /** WHO proposes — opaque to LoopOver, exactly like EvalScoreRecord.subject (#9215 §1): an SS58 address, a |
| 67 | + * pubkey, whatever the consumer keys on. Identity policy stays entirely subnet-side. */ |
| 68 | + subject: { kind: "agent"; id: string }; |
| 69 | + prediction: BenchmarkPrediction; |
| 70 | +}; |
| 71 | + |
| 72 | +const ACTION_KINDS: readonly string[] = ["merge", "close", "request_changes", "label", "hold"]; |
| 73 | +const CLOSE_REASON_CLASSES: readonly string[] = ["defective", "duplicate", "spam", "stale", "out_of_scope"]; |
| 74 | +const BENCHMARK_ID_MAX = 128; |
| 75 | +const WORK_UNIT_ID_MAX = 256; |
| 76 | +const SUBJECT_ID_MAX = 256; |
| 77 | +const BLOCKING_CONCERN_MAX = 500; |
| 78 | +const LABELS_MAX = 20; |
| 79 | +const LABEL_MAX = 100; |
| 80 | +const HORIZON_DAYS_MAX = 365; |
| 81 | +const SNAPSHOT_REF = /^[0-9a-f]{64}$/; |
| 82 | +const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/; |
| 83 | + |
| 84 | +const TASK_KEYS: readonly string[] = ["schemaVersion", "benchmarkId", "snapshotRef", "workUnitId", "horizonDays", "frozenAt"]; |
| 85 | +const PROPOSAL_KEYS: readonly string[] = ["schemaVersion", "benchmarkId", "snapshotRef", "workUnitId", "subject", "prediction"]; |
| 86 | +const SUBJECT_KEYS: readonly string[] = ["kind", "id"]; |
| 87 | +/** Exactly the keys each action kind may carry — the mechanism behind "parameters that do not apply to the |
| 88 | + * action are a validation error": `{kind: "merge", labels: [...]}` fails on `labels: unexpected key`. */ |
| 89 | +const ACTION_KEYS: Record<BenchmarkActionKind, readonly string[]> = { |
| 90 | + merge: ["kind"], |
| 91 | + hold: ["kind"], |
| 92 | + close: ["kind", "reasonClass"], |
| 93 | + request_changes: ["kind", "blockingConcern"], |
| 94 | + label: ["kind", "labels"], |
| 95 | +}; |
| 96 | + |
| 97 | +const nonEmptyString = (value: unknown): value is string => typeof value === "string" && value.length > 0; |
| 98 | + |
| 99 | +const isPlainObject = (value: unknown): value is Record<string, unknown> => |
| 100 | + typeof value === "object" && value !== null && !Array.isArray(value); |
| 101 | + |
| 102 | +/** The identity fields the task and the proposal SHARE — one implementation so they cannot drift. */ |
| 103 | +function validateTaskIdentity(record: Record<string, unknown>, errors: string[]): void { |
| 104 | + const benchmarkId = record["benchmarkId"]; |
| 105 | + if (!nonEmptyString(benchmarkId) || benchmarkId.length > BENCHMARK_ID_MAX) { |
| 106 | + errors.push(`benchmarkId: expected a non-empty string of at most ${BENCHMARK_ID_MAX} characters`); |
| 107 | + } |
| 108 | + const snapshotRef = record["snapshotRef"]; |
| 109 | + if (typeof snapshotRef !== "string" || !SNAPSHOT_REF.test(snapshotRef)) { |
| 110 | + errors.push("snapshotRef: expected 64 lowercase hex characters"); |
| 111 | + } |
| 112 | + const workUnitId = record["workUnitId"]; |
| 113 | + if (!nonEmptyString(workUnitId) || workUnitId.length > WORK_UNIT_ID_MAX) { |
| 114 | + errors.push(`workUnitId: expected a non-empty string of at most ${WORK_UNIT_ID_MAX} characters`); |
| 115 | + } |
| 116 | +} |
| 117 | + |
| 118 | +function validateAction(value: unknown, errors: string[]): void { |
| 119 | + if (!isPlainObject(value)) { |
| 120 | + errors.push("prediction.action: expected an object"); |
| 121 | + return; |
| 122 | + } |
| 123 | + const kind = value["kind"]; |
| 124 | + if (typeof kind !== "string" || !ACTION_KINDS.includes(kind)) { |
| 125 | + errors.push(`prediction.action.kind: expected one of ${ACTION_KINDS.join(", ")}`); |
| 126 | + return; |
| 127 | + } |
| 128 | + const allowed = ACTION_KEYS[kind as BenchmarkActionKind]; |
| 129 | + for (const key of Object.keys(value)) { |
| 130 | + if (!allowed.includes(key)) errors.push(`prediction.action.${key}: unexpected key for action "${kind}"`); |
| 131 | + } |
| 132 | + if (kind === "close") { |
| 133 | + const reasonClass = value["reasonClass"]; |
| 134 | + if (typeof reasonClass !== "string" || !CLOSE_REASON_CLASSES.includes(reasonClass)) { |
| 135 | + errors.push(`prediction.action.reasonClass: expected one of ${CLOSE_REASON_CLASSES.join(", ")}`); |
| 136 | + } |
| 137 | + } |
| 138 | + if (kind === "request_changes") { |
| 139 | + const blockingConcern = value["blockingConcern"]; |
| 140 | + if (!nonEmptyString(blockingConcern) || blockingConcern.trim().length === 0 || blockingConcern.length > BLOCKING_CONCERN_MAX) { |
| 141 | + errors.push(`prediction.action.blockingConcern: expected a non-blank string of at most ${BLOCKING_CONCERN_MAX} characters`); |
| 142 | + } |
| 143 | + } |
| 144 | + if (kind === "label") { |
| 145 | + const labels = value["labels"]; |
| 146 | + if (!Array.isArray(labels) || labels.length === 0 || labels.length > LABELS_MAX) { |
| 147 | + errors.push(`prediction.action.labels: expected 1-${LABELS_MAX} labels`); |
| 148 | + return; |
| 149 | + } |
| 150 | + const seen = new Set<string>(); |
| 151 | + for (let index = 0; index < labels.length; index += 1) { |
| 152 | + const label: unknown = labels[index]; |
| 153 | + if (!nonEmptyString(label) || label.trim().length === 0 || label.length > LABEL_MAX) { |
| 154 | + errors.push(`prediction.action.labels[${index}]: expected a non-blank string of at most ${LABEL_MAX} characters`); |
| 155 | + continue; |
| 156 | + } |
| 157 | + if (seen.has(label)) errors.push(`prediction.action.labels[${index}]: duplicate label "${label}"`); |
| 158 | + seen.add(label); |
| 159 | + } |
| 160 | + } |
| 161 | +} |
| 162 | + |
| 163 | +function validatePrediction(value: unknown, errors: string[]): void { |
| 164 | + if (!isPlainObject(value)) { |
| 165 | + errors.push("prediction: expected an object"); |
| 166 | + return; |
| 167 | + } |
| 168 | + const kind = value["kind"]; |
| 169 | + if (kind !== "abstain" && kind !== "act") { |
| 170 | + errors.push('prediction.kind: expected "abstain" or "act"'); |
| 171 | + return; |
| 172 | + } |
| 173 | + const allowed = kind === "abstain" ? ["kind"] : ["kind", "action"]; |
| 174 | + for (const key of Object.keys(value)) { |
| 175 | + if (!allowed.includes(key)) errors.push(`prediction.${key}: unexpected key`); |
| 176 | + } |
| 177 | + if (kind === "act") validateAction(value["action"], errors); |
| 178 | +} |
| 179 | + |
| 180 | +/** |
| 181 | + * Structurally validate an unknown value as a {@link BenchmarkTask}. Same contract as |
| 182 | + * {@link validateBenchmarkProposal} below (and `validateAttestationEnvelope` before both): pure, never |
| 183 | + * throws, one error per failing field path, unknown keys rejected. |
| 184 | + */ |
| 185 | +export function validateBenchmarkTask(value: unknown): { valid: true; task: BenchmarkTask } | { valid: false; errors: string[] } { |
| 186 | + if (!isPlainObject(value)) return { valid: false, errors: ["task: expected an object"] }; |
| 187 | + const errors: string[] = []; |
| 188 | + for (const key of Object.keys(value)) { |
| 189 | + if (!TASK_KEYS.includes(key)) errors.push(`${key}: unexpected key`); |
| 190 | + } |
| 191 | + if (value["schemaVersion"] !== 1) errors.push("schemaVersion: expected the literal 1"); |
| 192 | + validateTaskIdentity(value, errors); |
| 193 | + const horizonDays = value["horizonDays"]; |
| 194 | + if (typeof horizonDays !== "number" || !Number.isInteger(horizonDays) || horizonDays < 1 || horizonDays > HORIZON_DAYS_MAX) { |
| 195 | + errors.push(`horizonDays: expected an integer in [1, ${HORIZON_DAYS_MAX}]`); |
| 196 | + } |
| 197 | + const frozenAt = value["frozenAt"]; |
| 198 | + if (!nonEmptyString(frozenAt) || !ISO_DATETIME.test(frozenAt) || Number.isNaN(Date.parse(frozenAt))) { |
| 199 | + errors.push("frozenAt: expected an ISO-8601 datetime string"); |
| 200 | + } |
| 201 | + if (errors.length > 0) return { valid: false, errors }; |
| 202 | + return { valid: true, task: value as BenchmarkTask }; |
| 203 | +} |
| 204 | + |
| 205 | +/** |
| 206 | + * Structurally validate an unknown value as a {@link BenchmarkProposal} — the exact acceptance boundary any |
| 207 | + * candidate agent's output crosses. Never throws for ANY input; every rejection names the failing field |
| 208 | + * path, so an agent author can fix their emitter from the error list alone. Extra keys are rejected rather |
| 209 | + * than ignored: a parameter that does not apply to the chosen action (or a field this schema version does |
| 210 | + * not define) must fail loudly, never be silently dropped into an answer the scorer grades differently than |
| 211 | + * the agent intended. |
| 212 | + */ |
| 213 | +export function validateBenchmarkProposal( |
| 214 | + value: unknown, |
| 215 | +): { valid: true; proposal: BenchmarkProposal } | { valid: false; errors: string[] } { |
| 216 | + if (!isPlainObject(value)) return { valid: false, errors: ["proposal: expected an object"] }; |
| 217 | + const errors: string[] = []; |
| 218 | + for (const key of Object.keys(value)) { |
| 219 | + if (!PROPOSAL_KEYS.includes(key)) errors.push(`${key}: unexpected key`); |
| 220 | + } |
| 221 | + if (value["schemaVersion"] !== 1) errors.push("schemaVersion: expected the literal 1"); |
| 222 | + validateTaskIdentity(value, errors); |
| 223 | + |
| 224 | + const subject = value["subject"]; |
| 225 | + if (!isPlainObject(subject)) { |
| 226 | + errors.push("subject: expected an object"); |
| 227 | + } else { |
| 228 | + for (const key of Object.keys(subject)) { |
| 229 | + if (!SUBJECT_KEYS.includes(key)) errors.push(`subject.${key}: unexpected key`); |
| 230 | + } |
| 231 | + if (subject["kind"] !== "agent") errors.push('subject.kind: expected "agent"'); |
| 232 | + const id = subject["id"]; |
| 233 | + if (!nonEmptyString(id) || id.length > SUBJECT_ID_MAX) { |
| 234 | + errors.push(`subject.id: expected a non-empty string of at most ${SUBJECT_ID_MAX} characters`); |
| 235 | + } |
| 236 | + } |
| 237 | + |
| 238 | + validatePrediction(value["prediction"], errors); |
| 239 | + |
| 240 | + if (errors.length > 0) return { valid: false, errors }; |
| 241 | + return { valid: true, proposal: value as BenchmarkProposal }; |
| 242 | +} |
0 commit comments