Skip to content

Commit 701257f

Browse files
committed
feat(benchmark): maintainer action-space schema and proposal validator (#9260)
The frozen-repo benchmark's task format, fixed as validated types so two agents' outputs are comparable and the scorer downstream can assume well-formed input. - CLOSED action set (merge | close | request_changes | label | hold): an unrecognized action is a validation ERROR, never a silently-ignored or zero-scored entry. - Typed per-action parameters, enforced by a per-kind key allowlist, so a parameter that does not apply to the chosen action fails loudly ('labels: unexpected key for action "merge"') rather than being dropped into an answer the scorer grades differently than intended. close carries a reason CLASS (not free text, because realized history grades the class); request_changes carries a bounded concern; label carries a bounded, duplicate-free set; merge/hold carry nothing. - Abstention is FIRST-CLASS: declining a work unit is a recorded prediction feeding #9215's coverage metric, not a wrong answer, so a precise-but-narrow agent stays distinguishable from a broad-but-noisy one. An abstention carrying an action is contradictory and rejected. - The prediction horizon lives on the TASK, never the proposal, so an agent cannot pick a horizon that flatters its answer; a proposal echoes benchmarkId + snapshotRef + workUnitId so a submission cannot be replayed against a different snapshot. - Both validators are pure, never throw for ordinarily-invalid input, return one error per failing field path, and reject unknown keys -- the same contract as validateAttestationEnvelope, which they mirror deliberately. Exported from the engine barrel so a candidate agent author can validate locally against the exact code the harness runs. Closes #9260
1 parent d1a1df9 commit 701257f

3 files changed

Lines changed: 419 additions & 0 deletions

File tree

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
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+
}

packages/loopover-engine/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ export * from "./calibration/provider-track-record.js";
183183
export * from "./calibration/reliability-curve.js";
184184
export * from "./calibration/attestation-envelope.js";
185185
export * from "./calibration/attester.js";
186+
export * from "./calibration/benchmark-proposal.js";
186187
export {
187188
GOVERNOR_LEDGER_EVENT_TYPES,
188189
normalizeGovernorLedgerEvent,

0 commit comments

Comments
 (0)