Skip to content

Commit ba79d25

Browse files
authored
feat: add harness optimization primitives (#6)
* feat(0.7.1): SKILL.md as sole doc + SubprocessSandboxDriver constructor fallbacks Motivation: meta-analysis of starter-foundry's Gen 6→Round-0-post-Gen-9 arc surfaced 10+ incident-driven lessons about using this package. They lived nowhere canonical (README/CLAUDE.md described a stale 0.2-era API surface; the actual v0.7 builder-of-builders + sandbox harness exports had zero usage docs). Two shipped bugs traced to the same driver construct-vs-call cwd footgun. Consolidating into one authoritative doc + closing the footgun at the source. Changes: - .claude/skills/agent-eval/SKILL.md (NEW, sole source of truth) - minimal builder-of-builders path - 4 footguns (cwd-in-constructor, fallback-to-pass, fidelity-without- compile-gate, blob-vs-files channel) - 3 rules (both gates, single-source dispatch, Phase 1.5 walks entry points) - three-layer eval contract (builder → app-build → app-runtime) - regression tests every consumer should carry - extend-don't-duplicate index over the 100+ exports - muffled-gate pattern catalog (7 sub-shapes from shipped bugs) - README.md + CLAUDE.md → pointers to SKILL.md. No duplicated content. - SubprocessSandboxDriver constructor now accepts `{cwd?, env?}` as FALLBACKS when HarnessConfig omits them. Per-call config always wins. Pre-0.7.1 the constructor took no declared args, so TS tolerated `new Driver({cwd})` and silently dropped the arg at runtime — the exact shape of the Gen 8b promoter + Round-0 runtime eval bugs in starter-foundry. 0.7.1 makes the natural misuse do the obvious thing. New type: `SubprocessDriverDefaults`. Zero breaking changes for code that already reads cwd from HarnessConfig (the documented path). - tests/sandbox-harness.test.ts: +3 tests guarding the new defaults contract — default.cwd honored, per-call wins over default, defaults.env merges correctly. 322/322 tests pass (was 319; +3 new). typecheck clean. Version: 0.7.0 → 0.7.1. * feat: add harness optimization primitives
1 parent 9c8d46a commit ba79d25

6 files changed

Lines changed: 432 additions & 0 deletions

File tree

src/harness-optimizer.ts

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import { paretoFrontier, type Objective, type ParetoResult } from './pareto'
2+
import { aggregateRunScore, type RunScore, type RunScoreWeights } from './run-score'
3+
import { RunCritic, type RunTrace } from './run-critic'
4+
import type { SteeringBundle } from './steering'
5+
6+
export type HarnessIntervention =
7+
| 'continue'
8+
| 'plan'
9+
| 'audit'
10+
| 'recover'
11+
| 'repair'
12+
| 'verify'
13+
| 'final_gate'
14+
| 'wait_for_measurement'
15+
| 'abort'
16+
17+
export interface WorkflowTopology {
18+
id: string
19+
interventions: HarnessIntervention[]
20+
maxParallelBranches?: number
21+
metadata?: Record<string, unknown>
22+
}
23+
24+
export interface MeasurementPolicy {
25+
required: string[]
26+
optional?: string[]
27+
promoteOn?: Array<keyof RunScore | 'aggregate'>
28+
}
29+
30+
export interface HarnessVariant {
31+
id: string
32+
steering?: SteeringBundle
33+
topology?: WorkflowTopology
34+
measurement?: MeasurementPolicy
35+
budgets?: Record<string, number>
36+
models?: Record<string, string>
37+
reviewers?: Record<string, string>
38+
metadata?: Record<string, unknown>
39+
}
40+
41+
export interface HarnessScenario {
42+
id: string
43+
task: string
44+
split?: 'train' | 'validation' | 'test' | string
45+
metadata?: Record<string, unknown>
46+
}
47+
48+
export interface HarnessRunRequest {
49+
variant: HarnessVariant
50+
scenario: HarnessScenario
51+
trialIndex: number
52+
}
53+
54+
export interface HarnessAdapter {
55+
run(request: HarnessRunRequest): Promise<RunTrace>
56+
}
57+
58+
export interface HarnessRunResult {
59+
variant: HarnessVariant
60+
scenario: HarnessScenario
61+
trialIndex: number
62+
trace: RunTrace
63+
score: RunScore
64+
aggregate: number
65+
}
66+
67+
export interface HarnessVariantReport {
68+
variant: HarnessVariant
69+
runs: HarnessRunResult[]
70+
aggregateMean: number
71+
passRate: number
72+
costUsdMean: number
73+
wallSecondsMean: number
74+
scoreMean: RunScore
75+
}
76+
77+
export interface HarnessSelection {
78+
winner: HarnessVariantReport
79+
frontier: ParetoResult<HarnessVariantReport>
80+
reports: HarnessVariantReport[]
81+
}
82+
83+
export interface HarnessExperimentResult {
84+
results: HarnessRunResult[]
85+
selection: HarnessSelection
86+
}
87+
88+
export interface HarnessExperimentConfig {
89+
adapter: HarnessAdapter
90+
variants: HarnessVariant[]
91+
scenarios: HarnessScenario[]
92+
trialsPerScenario?: number
93+
parallelism?: number
94+
weights?: Partial<RunScoreWeights>
95+
objectives?: Array<Objective<HarnessVariantReport>>
96+
score?: (trace: RunTrace, request: HarnessRunRequest) => RunScore | Promise<RunScore>
97+
onResult?: (result: HarnessRunResult) => void | Promise<void>
98+
}
99+
100+
export const DEFAULT_HARNESS_OBJECTIVES: Array<Objective<HarnessVariantReport>> = [
101+
{ name: 'aggregate', direction: 'maximize', value: (r) => r.aggregateMean },
102+
{ name: 'pass_rate', direction: 'maximize', value: (r) => r.passRate },
103+
{ name: 'cost', direction: 'minimize', value: (r) => r.costUsdMean },
104+
{ name: 'wall', direction: 'minimize', value: (r) => r.wallSecondsMean },
105+
]
106+
107+
export async function runHarnessExperiment(config: HarnessExperimentConfig): Promise<HarnessExperimentResult> {
108+
const jobs = buildJobs(config)
109+
const critic = new RunCritic({ weights: config.weights })
110+
const score = config.score ?? ((trace: RunTrace) => critic.scoreTrace(trace))
111+
const results = await mapLimit(jobs, config.parallelism ?? 1, async (request) => {
112+
const trace = await config.adapter.run(request)
113+
const runScore = await score(trace, request)
114+
const result: HarnessRunResult = {
115+
variant: request.variant,
116+
scenario: request.scenario,
117+
trialIndex: request.trialIndex,
118+
trace,
119+
score: runScore,
120+
aggregate: aggregateRunScore(runScore, config.weights),
121+
}
122+
await config.onResult?.(result)
123+
return result
124+
})
125+
return { results, selection: selectHarnessVariant(results, config.objectives) }
126+
}
127+
128+
export function selectHarnessVariant(
129+
results: HarnessRunResult[],
130+
objectives: Array<Objective<HarnessVariantReport>> = DEFAULT_HARNESS_OBJECTIVES,
131+
): HarnessSelection {
132+
const reports = summarizeHarnessResults(results)
133+
if (reports.length === 0) throw new Error('selectHarnessVariant: no results')
134+
const frontier = paretoFrontier(reports, objectives)
135+
const candidates = frontier.frontier.length ? frontier.frontier : reports
136+
const winner = [...candidates].sort((a, b) => b.aggregateMean - a.aggregateMean)[0]
137+
if (!winner) throw new Error('selectHarnessVariant: no winner')
138+
return { winner, frontier, reports }
139+
}
140+
141+
export function summarizeHarnessResults(results: HarnessRunResult[]): HarnessVariantReport[] {
142+
const byVariant = new Map<string, HarnessRunResult[]>()
143+
for (const result of results) {
144+
byVariant.set(result.variant.id, [...(byVariant.get(result.variant.id) ?? []), result])
145+
}
146+
return [...byVariant.values()]
147+
.map((runs) => {
148+
const variant = runs[0]?.variant
149+
if (!variant) throw new Error('summarizeHarnessResults: empty variant bucket')
150+
return {
151+
variant,
152+
runs,
153+
aggregateMean: mean(runs.map((r) => r.aggregate)),
154+
passRate: mean(runs.map((r) => r.score.success)),
155+
costUsdMean: mean(runs.map((r) => r.score.costUsd)),
156+
wallSecondsMean: mean(runs.map((r) => r.score.wallSeconds)),
157+
scoreMean: meanRunScore(runs.map((r) => r.score)),
158+
}
159+
})
160+
.sort((a, b) => b.aggregateMean - a.aggregateMean)
161+
}
162+
163+
function buildJobs(config: HarnessExperimentConfig): HarnessRunRequest[] {
164+
if (config.variants.length === 0) throw new Error('runHarnessExperiment: at least one variant required')
165+
if (config.scenarios.length === 0) throw new Error('runHarnessExperiment: at least one scenario required')
166+
const trials = Math.max(1, Math.floor(config.trialsPerScenario ?? 1))
167+
const jobs: HarnessRunRequest[] = []
168+
for (const variant of config.variants) {
169+
for (const scenario of config.scenarios) {
170+
for (let trialIndex = 0; trialIndex < trials; trialIndex++) {
171+
jobs.push({ variant, scenario, trialIndex })
172+
}
173+
}
174+
}
175+
return jobs
176+
}
177+
178+
async function mapLimit<T, R>(
179+
items: T[],
180+
limit: number,
181+
fn: (item: T) => Promise<R>,
182+
): Promise<R[]> {
183+
const results: R[] = new Array(items.length)
184+
let next = 0
185+
const workerCount = Math.max(1, Math.min(Math.floor(limit), items.length))
186+
await Promise.all(Array.from({ length: workerCount }, async () => {
187+
while (next < items.length) {
188+
const index = next++
189+
const item = items[index]
190+
if (item === undefined) continue
191+
results[index] = await fn(item)
192+
}
193+
}))
194+
return results
195+
}
196+
197+
function mean(values: number[]): number {
198+
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0
199+
}
200+
201+
function meanRunScore(scores: RunScore[]): RunScore {
202+
return {
203+
success: mean(scores.map((s) => s.success)),
204+
goalProgress: mean(scores.map((s) => s.goalProgress)),
205+
repoGroundedness: mean(scores.map((s) => s.repoGroundedness)),
206+
driftPenalty: mean(scores.map((s) => s.driftPenalty)),
207+
toolUseQuality: mean(scores.map((s) => s.toolUseQuality)),
208+
patchQuality: mean(scores.map((s) => s.patchQuality)),
209+
testReality: mean(scores.map((s) => s.testReality)),
210+
finalGate: mean(scores.map((s) => s.finalGate)),
211+
reviewerBlockers: mean(scores.map((s) => s.reviewerBlockers)),
212+
costUsd: mean(scores.map((s) => s.costUsd)),
213+
wallSeconds: mean(scores.map((s) => s.wallSeconds)),
214+
notes: scores.flatMap((s) => s.notes ?? []),
215+
}
216+
}

src/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,26 @@ export type {
138138
SteeringOptimizerConfig,
139139
AxSteeringOptimizerConfig,
140140
} from './steering-optimizer'
141+
export {
142+
DEFAULT_HARNESS_OBJECTIVES,
143+
runHarnessExperiment,
144+
selectHarnessVariant,
145+
summarizeHarnessResults,
146+
} from './harness-optimizer'
147+
export type {
148+
HarnessAdapter,
149+
HarnessExperimentConfig,
150+
HarnessExperimentResult,
151+
HarnessIntervention,
152+
HarnessRunRequest,
153+
HarnessRunResult,
154+
HarnessScenario,
155+
HarnessSelection,
156+
HarnessVariant,
157+
HarnessVariantReport,
158+
MeasurementPolicy,
159+
WorkflowTopology,
160+
} from './harness-optimizer'
141161
export {
142162
JudgeRunner,
143163
runJudgeFleet,

src/run-critic.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ export class RunCritic {
5050
const toolSpans = trace.spans.filter((s): s is Extract<Span, { kind: 'tool' }> => s.kind === 'tool')
5151
const judgeSpans = trace.spans.filter((s): s is Extract<Span, { kind: 'judge' }> => s.kind === 'judge')
5252
const sandboxSpans = trace.spans.filter((s): s is Extract<Span, { kind: 'sandbox' }> => s.kind === 'sandbox')
53+
const finalGateSpans = judgeSpans.filter((span) =>
54+
span.dimension === 'final_gate' || span.attributes?.finalGate === true,
55+
)
5356

5457
const success = trace.run.outcome?.pass === true ? 1 : trace.run.status === 'completed' ? 0.5 : 0
5558
if (!success) notes.push('run did not complete with pass=true')
@@ -78,6 +81,17 @@ export class RunCritic {
7881
: 0
7982
if (!testReality) notes.push('no real test/build evidence recorded')
8083

84+
const blockerSpans = judgeSpans.filter((span) =>
85+
isBlockingJudge(span),
86+
)
87+
const finalGateBlockers = finalGateSpans.filter((span) => isBlockingJudge(span))
88+
const finalGate = finalGateSpans.length ? (finalGateBlockers.length ? 0 : 1) : success
89+
if (finalGateBlockers.length) notes.push(`final gate blocked by ${finalGateBlockers.length} reviewer(s)`)
90+
else if (!finalGateSpans.length) notes.push('no final gate judgment recorded')
91+
92+
const reviewerBlockers = judgeSpans.length ? blockerSpans.length / judgeSpans.length : 0
93+
if (reviewerBlockers) notes.push(`detected ${blockerSpans.length} blocking reviewer signal(s)`)
94+
8195
const positiveGroundingSignals =
8296
patchEvidence +
8397
sandboxSpans.length +
@@ -108,6 +122,8 @@ export class RunCritic {
108122
toolUseQuality,
109123
patchQuality,
110124
testReality,
125+
finalGate,
126+
reviewerBlockers,
111127
costUsd,
112128
wallSeconds,
113129
notes,
@@ -130,3 +146,15 @@ function normalizeJudgeScore(score: number): number {
130146
function looksRepoGrounded(text: string): boolean {
131147
return /(?:src\/|tests?\/|package\.json|tsconfig|\.ts\b|\.tsx\b|git status|pnpm |npm |vitest|pytest|jest)/i.test(text)
132148
}
149+
150+
function isBlockingJudge(span: Extract<Span, { kind: 'judge' }>): boolean {
151+
return span.attributes?.blocking === true ||
152+
span.attributes?.verdict === 'BLOCKING' ||
153+
positiveNumber(span.attributes?.blockingFindings) ||
154+
positiveNumber(span.attributes?.highFindings) ||
155+
span.score <= 2
156+
}
157+
158+
function positiveNumber(value: unknown): boolean {
159+
return typeof value === 'number' && value > 0
160+
}

src/run-score.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export interface RunScore {
66
toolUseQuality: number
77
patchQuality: number
88
testReality: number
9+
finalGate: number
10+
reviewerBlockers: number
911
costUsd: number
1012
wallSeconds: number
1113
notes?: string[]
@@ -19,6 +21,8 @@ export interface RunScoreWeights {
1921
toolUseQuality: number
2022
patchQuality: number
2123
testReality: number
24+
finalGate: number
25+
reviewerBlockers: number
2226
costUsd: number
2327
wallSeconds: number
2428
}
@@ -31,6 +35,8 @@ export const DEFAULT_RUN_SCORE_WEIGHTS: RunScoreWeights = {
3135
toolUseQuality: 1,
3236
patchQuality: 1.25,
3337
testReality: 1.5,
38+
finalGate: 3,
39+
reviewerBlockers: -2,
3440
costUsd: -0.2,
3541
wallSeconds: -0.1,
3642
}
@@ -48,6 +54,8 @@ export function aggregateRunScore(
4854
w.toolUseQuality * clamp01(score.toolUseQuality) +
4955
w.patchQuality * clamp01(score.patchQuality) +
5056
w.testReality * clamp01(score.testReality) +
57+
w.finalGate * clamp01(score.finalGate) +
58+
w.reviewerBlockers * clamp01(score.reviewerBlockers) +
5159
w.costUsd * Math.max(0, score.costUsd) +
5260
w.wallSeconds * Math.max(0, score.wallSeconds / 60)
5361
)

0 commit comments

Comments
 (0)