From 81c01dc39e096391d96e21f2cc46c3d56910d249 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 21:15:30 +0000 Subject: [PATCH] Move reviseRubric into the adversary config and factor round boilerplate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rubric revision after a standing challenge is the adversary's sole responsibility, so HarnessInput.adversary is now an AdversaryConfig — a required challenge callback plus an optional reviseRubric callback — and the top-level reviseRubric option is gone. The revision is dispatched on the bus as the adversary actor, and the named AdversaryTurn and RubricRevisionTurn types replace the inline turn shapes. The run loop's repeated object initialization now goes through small factories: a once-computed optional-signal spread, a contextOf(actor) helper for provider-shaped bus history, shared verdict-request evidence, and a record() factory replacing four near-identical rounds.push blocks. The provider adapter gains the same maybeSignal helper. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0175hpwygwMcHwR4xWFVgwST --- docs/architecture.md | 2 +- packages/harness/README.md | 6 +- packages/harness/src/harness.ts | 131 +++++++++++++------------- packages/harness/src/index.ts | 3 + packages/harness/test/harness.test.ts | 27 +++--- src/providers/harness.ts | 26 ++--- 6 files changed, 103 insertions(+), 92 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 4b31d91..00a3b6e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -136,7 +136,7 @@ by an exact pass/fail decision, and records its outcome; the judge is just another actor whose verdicts land on the bus as ordinary `agent.decision` messages, and denied actions never execute. AgentV supplies objective evidence; judge rejection never invents feedback. Teacher feedback guides the -student, and judge-approved adversarial challenges require the teacher to +student, and judge-approved adversarial challenges require the adversary to improve the rubric. Agent and skill lifecycle management is deliberately outside the harness. Consumers may evolve agents independently and inject the resulting callbacks diff --git a/packages/harness/README.md b/packages/harness/README.md index 4007c99..4f3d9b1 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -13,8 +13,7 @@ Every other role has a default, and all defaults follow one evidence convention — feedback is the verdict: - **judge** accepts any input and returns only `pass` or `fail`. Unset, a candidate passes when the teacher reports no feedback, a challenge stands when the adversary reports evidence, and actions are logged ungated. -- **adversary** receives only the artifact under test and its own prior messages, and reports `{ challenge, feedback }`. Unset, a passing candidate is accepted without adversarial review. -- **reviseRubric** tightens the rubric after a standing challenge. Unset, the challenge evidence is appended as new criteria. +- **adversary** is a config of its own: its required `challenge` callback receives only the artifact under test and its own prior messages, and reports `{ challenge, feedback }`; its optional `reviseRubric` callback tightens the rubric after a standing challenge — unset, the challenge evidence is appended as new criteria. With no adversary at all, a passing candidate is accepted without adversarial review. - **bus** defaults to an in-memory write-ahead bus, returned on the run result for auditing. The harness does not create, configure, or select agents — no models, prompts, @@ -63,8 +62,7 @@ const result = await harness.run({ student: myStudent, teacher: myTeacher, judge: myJudge, - adversary: myAdversary, - reviseRubric: myRubricRevision, + adversary: { challenge: myAdversary, reviseRubric: myRubricRevision }, }); ``` diff --git a/packages/harness/src/harness.ts b/packages/harness/src/harness.ts index 480b16d..f5e0bc6 100644 --- a/packages/harness/src/harness.ts +++ b/packages/harness/src/harness.ts @@ -48,6 +48,40 @@ export interface RubricRevision { readonly feedback: readonly TFeedback[]; } +/** The challenge turn is deliberately narrow: the adversary sees the task and + * only its own prior messages, never the teacher's assessment or the rubric. */ +export interface AdversaryTurn { + readonly task: unknown; + readonly context: readonly AgentBusEntry[]; + readonly signal?: AbortSignal; +} + +export interface RubricRevisionTurn { + readonly task: unknown; + readonly candidate: TCandidate; + readonly assessment: TAssessment; + readonly rubric: string; + readonly context: readonly AgentBusEntry[]; + readonly signal?: AbortSignal; +} + +/** The adversary role in full. Challenging accepted candidates and tightening + * the rubric when a challenge stands are one responsibility, so both callbacks + * live here; only the challenge is required. */ +export interface AdversaryConfig { + /** Challenges a candidate the judge accepted. */ + readonly challenge: ( + candidate: TCandidate, + turn: AdversaryTurn, + ) => AdversaryResult | Promise>; + /** Revises the rubric after a standing challenge; when unset the challenge + * evidence is appended to the rubric as new criteria. */ + readonly reviseRubric?: ( + challenge: AdversaryResult, + turn: RubricRevisionTurn, + ) => RubricRevision | Promise>; +} + /** Every request carries the evidence the default verdicts weigh, so a * configured judge can apply the same convention even when its bus context is * windowed or redacted. */ @@ -93,25 +127,10 @@ export interface HarnessInput { readonly judge?: ( request: JudgeRequest, ) => JudgeDecision | Promise; - /** Challenges candidates the judge accepted. When unset, a passing - * candidate is accepted without adversarial review. */ - readonly adversary?: ( - candidate: TCandidate, - turn: Readonly<{ task: unknown; context: readonly AgentBusEntry[]; signal?: AbortSignal }>, - ) => AdversaryResult | Promise>; - /** Revises the rubric after a standing challenge; when unset the challenge - * evidence is appended to the rubric as new criteria. */ - readonly reviseRubric?: ( - challenge: AdversaryResult, - turn: Readonly<{ - task: unknown; - candidate: TCandidate; - assessment: TAssessment; - rubric: string; - context: readonly AgentBusEntry[]; - signal?: AbortSignal; - }>, - ) => RubricRevision | Promise>; + /** Challenges candidates the judge accepted, and optionally revises the + * rubric when a challenge stands. When unset, a passing candidate is + * accepted without adversarial review. */ + readonly adversary?: AdversaryConfig; /** Shapes bus history into actor context; full history when unset. */ readonly contextProvider?: ContextProvider; readonly signal?: AbortSignal; @@ -142,12 +161,16 @@ export function defineTrainingHarness( const bus = input.bus ?? new WriteAheadAgentBus(); const provide = input.contextProvider ?? fullHistory; const judge = input.judge; - const revise = input.reviseRubric ?? appendCriteria; let rubric: string = rubricText.parse(input.rubric); const rounds: HarnessRound[] = []; let feedback: readonly TFeedback[] = []; let previousCandidate: string | undefined; + // Factories for the shapes every turn rebuilds: the optional abort + // signal and provider-shaped context. + const signal = input.signal === undefined ? {} : { signal: input.signal }; + const contextOf = async (actor?: string) => provide(await bus.read(actor)); + // Every actor invocation is written ahead; a configured judge also // gates it, and every verdict — the judge's or the evidence // convention's — lands on the bus as an ordinary judge message. @@ -173,12 +196,7 @@ export function defineTrainingHarness( for (let round = 1; round <= maxRounds; round += 1) { input.signal?.throwIfAborted(); const turn: StudentTurn = Object.freeze({ - round, - task: input.task, - rubric, - feedback, - context: await provide(await bus.read()), - ...(input.signal === undefined ? {} : { signal: input.signal }), + round, task: input.task, rubric, feedback, context: await contextOf(), ...signal, }); const candidate = await dispatch(actors.student, kinds.propose, { round, task: input.task, rubric, feedback }, () => input.student(turn)); @@ -190,67 +208,54 @@ export function defineTrainingHarness( const assessment = await dispatch(actors.teacher, kinds.assess, { round, candidateId }, () => input.teacher(candidate, turn)); input.signal?.throwIfAborted(); - const candidateDecision = await decide({ candidateId }, { - subject: "candidate", - task: input.task, - candidate, - assessment: assessment.assessment, - feedback: assessment.feedback, - rubric, - context: [], - }, () => assessment.feedback.length === 0 ? "pass" : "fail"); + // The evidence both verdict requests carry, and one factory for + // every shape a recorded round takes. + const evidence = { task: input.task, candidate, rubric, context: [] } as const; + const candidateDecision = await decide({ candidateId }, + { subject: "candidate", ...evidence, assessment: assessment.assessment, feedback: assessment.feedback }, + () => assessment.feedback.length === 0 ? "pass" : "fail"); + const record = (challenged?: Readonly<{ challenge: TChallenge; decision: JudgeDecision }>) => + rounds.push(Object.freeze({ + round, candidate, assessment: assessment.assessment, judgeDecision: candidateDecision, + ...(challenged === undefined ? {} : { adversary: Object.freeze(challenged) }), rubric, + })); if (candidateDecision === "fail") { - rounds.push(Object.freeze({ round, candidate, assessment: assessment.assessment, judgeDecision: candidateDecision, rubric })); + record(); feedback = Object.freeze([...assessment.feedback]); continue; } const adversary = input.adversary; if (adversary === undefined) { - rounds.push(Object.freeze({ round, candidate, assessment: assessment.assessment, judgeDecision: candidateDecision, rubric })); + record(); return result("accepted"); } const challenge = await dispatch(actors.adversary, kinds.challenge, { candidateId }, async () => - adversary(candidate, { - task: input.task, - context: await provide(await bus.read(actors.adversary)), - ...(input.signal === undefined ? {} : { signal: input.signal }), - })); + adversary.challenge(candidate, { task: input.task, context: await contextOf(actors.adversary), ...signal })); input.signal?.throwIfAborted(); - const challengeDecision = await decide({ candidateId }, { - subject: "adversary", - task: input.task, - candidate, - challenge: challenge.challenge, - feedback: challenge.feedback, - rubric, - context: [], - }, () => challenge.feedback.length > 0 ? "pass" : "fail"); + const challengeDecision = await decide({ candidateId }, + { subject: "adversary", ...evidence, challenge: challenge.challenge, feedback: challenge.feedback }, + () => challenge.feedback.length > 0 ? "pass" : "fail"); if (challengeDecision === "fail") { - rounds.push(Object.freeze({ round, candidate, assessment: assessment.assessment, judgeDecision: candidateDecision, - adversary: Object.freeze({ challenge: challenge.challenge, decision: challengeDecision }), rubric })); + record({ challenge: challenge.challenge, decision: challengeDecision }); return result("accepted"); } - const revision = await dispatch(actors.teacher, kinds.reviseRubric, { round, candidateId }, async () => + const revise = adversary.reviseRubric ?? appendCriteria; + const revision = await dispatch(actors.adversary, kinds.reviseRubric, { round, candidateId }, async () => revise(challenge, { - task: input.task, - candidate, - assessment: assessment.assessment, - rubric, - context: await provide(await bus.read()), - ...(input.signal === undefined ? {} : { signal: input.signal }), + task: input.task, candidate, assessment: assessment.assessment, rubric, + context: await contextOf(), ...signal, })); input.signal?.throwIfAborted(); const revised: string = rubricText.parse(revision.rubric); - if (revised === rubric) throw new Error("teacher must improve the rubric after an approved adversarial challenge"); + if (revised === rubric) throw new Error("adversary must improve the rubric after a standing challenge"); rubric = revised; feedback = Object.freeze([...revision.feedback]); - rounds.push(Object.freeze({ round, candidate, assessment: assessment.assessment, judgeDecision: candidateDecision, - adversary: Object.freeze({ challenge: challenge.challenge, decision: challengeDecision }), rubric })); + record({ challenge: challenge.challenge, decision: challengeDecision }); } return result("exhausted"); diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index a722ff2..6cd5220 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -9,7 +9,9 @@ export type { AbsolutePath, AgentBusEntry, AgentMessage } from "./schema.js"; export { defaultMaxRounds, defineTrainingHarness } from "./harness.js"; export type { + AdversaryConfig, AdversaryResult, + AdversaryTurn, ContextProvider, HarnessInput, HarnessRound, @@ -17,6 +19,7 @@ export type { HarnessSettings, JudgeRequest, RubricRevision, + RubricRevisionTurn, StudentTurn, TeacherResult, TrainingHarness, diff --git a/packages/harness/test/harness.test.ts b/packages/harness/test/harness.test.ts index 1707a67..5f210b4 100644 --- a/packages/harness/test/harness.test.ts +++ b/packages/harness/test/harness.test.ts @@ -45,7 +45,7 @@ describe("training harness", () => { rubric: "Initial rubric", student: ({ round }) => `candidate-${round}`, teacher: () => ({ assessment: "evidence", feedback: [] }), - adversary: () => ({ challenge: "challenge", feedback: evidence.shift() ?? [] }), + adversary: { challenge: () => ({ challenge: "challenge", feedback: evidence.shift() ?? [] }) }, }); // Round one's challenge stood and tightened the rubric by default; @@ -58,7 +58,7 @@ describe("training harness", () => { it("uses one callback loop for teacher feedback, judge decisions, and adversarial review", async () => { const callbacks = await loopCallbacks(["fail", "pass", "fail"]); const student = vi.fn(({ round }) => `candidate-${round}`); - const adversary = vi.fn(() => ({ challenge: "counterexample", feedback: [] })); + const challenge = vi.fn(() => ({ challenge: "counterexample", feedback: [] })); const harness = defineTrainingHarness({ maxRounds: 2 }); const result = await harness.run({ @@ -67,14 +67,13 @@ describe("training harness", () => { rubric: "Candidate must be correct", student, teacher: () => ({ assessment: "evidence", feedback: ["teacher-only feedback"] }), - adversary, - reviseRubric: () => ({ rubric: "unused", feedback: [] }), + adversary: { challenge, reviseRubric: () => ({ rubric: "unused", feedback: [] }) }, }); expect(result.outcome).toBe("accepted"); expect(student.mock.calls[1]?.[0].feedback).toEqual(["teacher-only feedback"]); expect(student.mock.calls[1]?.[0].context.length).toBeGreaterThan(0); - expect(adversary).toHaveBeenCalledOnce(); + expect(challenge).toHaveBeenCalledOnce(); expect(result.final.adversary).toEqual({ challenge: "counterexample", decision: "fail" }); // The judge is just another actor: its verdicts are ordinary messages. const judgeEntries = await callbacks.bus.read("judge"); @@ -97,7 +96,7 @@ describe("training harness", () => { controller.abort(); return { assessment: null, feedback: [] }; }, - adversary: () => ({ challenge: "challenge", feedback: [] }), + adversary: { challenge: () => ({ challenge: "challenge", feedback: [] }) }, })).rejects.toThrow(); }); @@ -113,11 +112,13 @@ describe("training harness", () => { rubric: "Check tests", student: () => "candidate", teacher, - adversary: (_candidate, turn) => { - expect(JSON.stringify(turn)).not.toMatch(/teacher|rubric|student/i); - return { challenge: "edge-case failure", feedback: ["handle edge case"] }; + adversary: { + challenge: (_candidate, turn) => { + expect(JSON.stringify(turn)).not.toMatch(/teacher|rubric|student/i); + return { challenge: "edge-case failure", feedback: ["handle edge case"] }; + }, + reviseRubric, }, - reviseRubric, }); expect(result.outcome).toBe("exhausted"); @@ -197,8 +198,10 @@ describe("training harness", () => { return `candidate-${round}`; }, teacher: () => ({ assessment: "evidence", feedback: [] }), - adversary: () => ({ challenge: "challenge", feedback: [] }), - reviseRubric: () => ({ rubric: "revised", feedback: [] }), + adversary: { + challenge: () => ({ challenge: "challenge", feedback: [] }), + reviseRubric: () => ({ rubric: "revised", feedback: [] }), + }, }); // By round two the bus holds many entries; the provider windowed them to one. diff --git a/src/providers/harness.ts b/src/providers/harness.ts index ec0daaf..5ebbe92 100644 --- a/src/providers/harness.ts +++ b/src/providers/harness.ts @@ -52,23 +52,19 @@ export function createHarnessLoop(options: HarnessLoopOptions = {}): TrainingLoo ...(options.judge === undefined ? {} : { judge: options.judge }), task: { trainable: input.trainableId, objective: input.objective }, rubric: input.rubric, - ...(input.signal === undefined ? {} : { signal: input.signal }), + ...maybeSignal(input.signal), // The governed harness explores one candidate per round; fan-out stays 1. student: ({ round, feedback, signal }) => - input.propose({ round, slot: 1, feedback, ...(signal === undefined ? {} : { signal }) }), + input.propose({ round, slot: 1, feedback, ...maybeSignal(signal) }), teacher: async (candidate, { round, signal }) => { - const review = await input.review(candidate, { - label: `candidate-${round}`, - ...(signal === undefined ? {} : { signal }), - }); + const review = await input.review(candidate, { label: `candidate-${round}`, ...maybeSignal(signal) }); return { assessment: review, feedback: review.decision.failures }; }, - adversary: async (candidate, { signal }) => { - const challenge = await input.review(candidate, { - label: `adversary-${candidate.id}`, - ...(signal === undefined ? {} : { signal }), - }); - return { challenge, feedback: challenge.decision.failures }; + adversary: { + challenge: async (candidate, { signal }) => { + const challenge = await input.review(candidate, { label: `adversary-${candidate.id}`, ...maybeSignal(signal) }); + return { challenge, feedback: challenge.decision.failures }; + }, }, }); return { @@ -77,3 +73,9 @@ export function createHarnessLoop(options: HarnessLoopOptions = {}): TrainingLoo }; }; } + +/** Spreads an abort signal only when one exists, so optional-property types + * never receive an explicit `undefined`. */ +function maybeSignal(signal: AbortSignal | undefined): { signal: AbortSignal } | Record { + return signal === undefined ? {} : { signal }; +}