mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
Two related v0.42.0.0 bugs that conspired to make `runSkillOpt` structurally
unable to accept any candidate edit. Either alone would have killed self-evolution;
together they made the loop a no-op for every input.
**Bug 1 (orchestrator gap):** `runOptimizationLoop` in orchestrator.ts called
`runReflect({successes: [], failures: []})` with hardcoded empty arrays. The
forward gate's `scoredRollouts` were computed then voided. `runReflect`
short-circuits both modes when their batches are empty, so the optimizer was
never asked to propose an edit. Every step hit the no_edits_applied branch.
Fix: add `scoredRollouts: ScoredRollout[]` to `GateResult` and
`runsPerTask?: number` to `ValidateGateOpts`. Forward pass uses
`runsPerTask: 1`; orchestrator partitions returned rollouts by `score >= 0.5`
and threads real successes + failures into `runReflect`.
**Bug 2 (parser misuse):** `parseEditsResponse` in reflect.ts routed every
optimizer response through `parseJudgeJson` first. `parseJudgeJson` looks for
a `score` key (it's a judge-output parser, not an edits parser) and returns
null for any JSON without one — including the well-formed `{"edits": [...]}`
the optimizer is contractually required to emit. The function then early-
returned `[]` and the actual `tryExtractEdits` path on the next line was
unreachable dead code.
Fix: drop the wrong-typed guard. `parseEditsResponse` now calls
`tryExtractEdits` directly. Export it so `reflect.test.ts` can pin the
contract independently of the chat transport.
**Why this slipped through 152 prior skillopt tests:** zero unit coverage
of `parseEditsResponse` or `runReflect`. The existing E2E `all-reject` case
asserted no_improvement (which was true for the wrong reason — empty edits,
not gate rejection). Both bugs were structurally invisible to the existing
test surface.
**New coverage:**
- `test/skillopt/reflect.test.ts` (15 cases):
- 8 `parseEditsResponse` cases including the IRON-RULE regression pin
for the v0.42.0.1 fix (`{"edits": [...]}` JSON must survive the parser).
- 7 `runReflect` D7 contract cases: both modes fire, empty-batch skips,
additive token usage, one-mode-throws-other-still-works, rejected-buffer
flows into anti-bias prompt.
- Documents the trailing-comma limitation as an explicit out-of-scope pin
(so a future tightening of `tryExtractEdits` lights this test up
intentionally).
- `test/e2e/skillopt-loop.serial.test.ts` (7 cases):
- HAPPY PATH: stubbed `gateway.chat` acts as both target agent (emits
sections based on skill content) and optimizer (proposes a real
add-Citations edit). Drives `runSkillOpt` end-to-end against PGLite.
Asserts outcome=accepted, SKILL.md mutated with new section,
frontmatter preserved (D5), history has one committed row,
best.md mirrors disk, delta > epsilon, receipt fields populated.
- 5 broken cases (each isolates a distinct orchestrator-visible failure):
1. Below-baseline regression: optimizer proposes a destructive edit;
gate rejects with reason=below_baseline; SKILL.md unchanged;
rejected-buffer captures the bad edit for anti-bias context.
2. Malformed reflect JSON: orchestrator degrades gracefully to
no_improvement without crashing.
3. Anchor-not-found: applyEditBatch rejects all; sel gate skipped;
rejected-buffer captures with reason=apply_failed.
4. Budget exhausted mid-step: outcome=aborted, no pending rows survive.
5. Converged-skill re-run: starting from already-perfect skill →
no_improvement (no thrash on a well-tuned starting point).
- IDEMPOTENT RE-RUN: drive runSkillOpt twice in sequence. Run 1 accepts.
Run 2 sees improved baseline, no failures, returns no_improvement.
SKILL.md byte-identical to post-run-1; history still has exactly 1
committed row. Proves stability at the fixed point.
All hermetic (no DATABASE_URL, no API keys). PGLite in-memory engine,
tempdir SKILL.md + benchmark, stubbed gateway.chat via
`__setChatTransportForTests`. `.serial.test.ts` because the stub installs
module state and the loop walks shared disk state across epochs.
Test counts after fix: 174 skillopt-surface tests pass (149 pre-existing
unit + 15 new reflect unit + 3 existing E2E + 7 new E2E). Typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
/**
|
|
* SkillOpt reflect unit tests.
|
|
*
|
|
* Pinned regressions:
|
|
* - parseEditsResponse must NOT route through parseJudgeJson (which checks
|
|
* for a 'score' key and silently drops all optimizer output that has
|
|
* 'edits' instead). v0.42.0.0 shipped with that bug; every optimizer
|
|
* call produced zero edits and the orchestrator could never accept
|
|
* anything. Fixed v0.42.0.1 by dropping the wrong-typed guard.
|
|
*
|
|
* - runReflect must call FAILURE/SUCCESS reflect modes only when their
|
|
* batches are non-empty (D7 paper-faithful semantics).
|
|
*
|
|
* - Token-usage accumulation across the two reflect calls must be additive.
|
|
*
|
|
* - Errors in one mode (e.g. failure-reflect throws) must NOT swallow the
|
|
* other mode's edits.
|
|
*/
|
|
|
|
import { describe, expect, test } from 'bun:test';
|
|
import { parseEditsResponse, runReflect } from '../../src/core/skillopt/reflect.ts';
|
|
import type { ChatOpts, ChatResult } from '../../src/core/ai/gateway.ts';
|
|
import type { ScoredRollout, Trajectory } from '../../src/core/skillopt/types.ts';
|
|
|
|
// ─── parseEditsResponse ─────────────────────────────────────────────────────
|
|
|
|
describe('parseEditsResponse', () => {
|
|
test('parses minimal {edits: [...]} shape', () => {
|
|
const out = parseEditsResponse(
|
|
JSON.stringify({ edits: [{ op: 'add', anchor: 'People', content: 'X', reason: 'r' }] }),
|
|
);
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]).toMatchObject({ op: 'add', anchor: 'People', content: 'X', reason: 'r' });
|
|
});
|
|
|
|
test('REGRESSION v0.42.0.1: edits-only JSON survives the parser', () => {
|
|
// Pre-fix this returned [] because parseJudgeJson required a 'score' key.
|
|
// If this regresses, every optimizer call silently produces zero edits.
|
|
const out = parseEditsResponse('{"edits":[{"op":"delete","target":"foo","reason":"r"}]}');
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]).toMatchObject({ op: 'delete', target: 'foo' });
|
|
});
|
|
|
|
test('strips ```json``` fences', () => {
|
|
const out = parseEditsResponse(
|
|
'```json\n{"edits":[{"op":"replace","target":"x","replacement":"y"}]}\n```',
|
|
);
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]).toMatchObject({ op: 'replace', target: 'x', replacement: 'y' });
|
|
});
|
|
|
|
test('extracts first {...} from prose-wrapped output', () => {
|
|
const out = parseEditsResponse(
|
|
'Here is the edit: {"edits":[{"op":"add","anchor":"H","content":"C"}]} hope this helps',
|
|
);
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]).toMatchObject({ op: 'add', anchor: 'H', content: 'C' });
|
|
});
|
|
|
|
test('contract scope: trailing-comma repair is NOT supported by tryExtractEdits', () => {
|
|
// Documented limitation: trailing commas in optimizer JSON break parsing.
|
|
// The optimizer prompt forbids them; if the model emits one anyway we lose
|
|
// the batch this call. Tightening this would mean folding the repair pass
|
|
// from parseJudgeJson back in — currently out of scope. Pin the limitation
|
|
// so a future tightening intentionally lights this test up.
|
|
const out = parseEditsResponse('{"edits":[{"op":"add","anchor":"H","content":"C",},]}');
|
|
expect(out).toEqual([]);
|
|
});
|
|
|
|
test('returns [] for malformed JSON without throwing', () => {
|
|
expect(parseEditsResponse('{not valid json at all')).toEqual([]);
|
|
expect(parseEditsResponse('')).toEqual([]);
|
|
expect(parseEditsResponse(' ')).toEqual([]);
|
|
expect(parseEditsResponse('plain prose no braces')).toEqual([]);
|
|
});
|
|
|
|
test('returns [] when edits key is absent or wrong type', () => {
|
|
expect(parseEditsResponse('{"score": 0.8}')).toEqual([]);
|
|
expect(parseEditsResponse('{"edits": "not an array"}')).toEqual([]);
|
|
expect(parseEditsResponse('{"edits": null}')).toEqual([]);
|
|
});
|
|
|
|
test('drops malformed individual edits but keeps valid ones', () => {
|
|
const out = parseEditsResponse(JSON.stringify({
|
|
edits: [
|
|
{ op: 'add', anchor: 'H', content: 'C' }, // valid
|
|
{ op: 'add' }, // missing anchor + content
|
|
{ op: 'delete', target: 'T' }, // valid
|
|
{ op: 'replace', target: 'T' }, // missing replacement
|
|
{ op: 'invalid_op', anchor: 'X', content: 'Y' }, // unknown op
|
|
null, // garbage
|
|
'string', // garbage
|
|
],
|
|
}));
|
|
expect(out).toHaveLength(2);
|
|
expect(out[0]).toMatchObject({ op: 'add' });
|
|
expect(out[1]).toMatchObject({ op: 'delete', target: 'T' });
|
|
});
|
|
|
|
test('caps reason to string-only (drops non-string reasons)', () => {
|
|
const out = parseEditsResponse(JSON.stringify({
|
|
edits: [{ op: 'add', anchor: 'H', content: 'C', reason: 42 }],
|
|
}));
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]!.reason).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ─── runReflect ─────────────────────────────────────────────────────────────
|
|
|
|
function makeTrajectory(task_id: string, final_text: string): Trajectory {
|
|
return {
|
|
task_id,
|
|
task: `Task ${task_id}`,
|
|
final_text,
|
|
tool_calls: [],
|
|
usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
turns: 1,
|
|
stop_reason: 'end',
|
|
duration_ms: 10,
|
|
};
|
|
}
|
|
|
|
function makeScored(task_id: string, score: number, text = ''): ScoredRollout {
|
|
return { trajectory: makeTrajectory(task_id, text), score };
|
|
}
|
|
|
|
function makeChatResult(text: string): ChatResult {
|
|
return {
|
|
text,
|
|
blocks: [{ type: 'text', text }],
|
|
stopReason: 'end',
|
|
usage: { input_tokens: 100, output_tokens: 20, cache_read_tokens: 5, cache_creation_tokens: 1 },
|
|
model: 'anthropic:claude-opus-4-7',
|
|
providerId: 'anthropic',
|
|
};
|
|
}
|
|
|
|
describe('runReflect (D7 two-call contract)', () => {
|
|
test('non-empty failures + non-empty successes: both modes fire, edits collected', async () => {
|
|
const calls: Array<{ mode: 'failure' | 'success' }> = [];
|
|
const chatFn = async (opts: ChatOpts): Promise<ChatResult> => {
|
|
const isFailure = (opts.system ?? '').includes('FAILURE TRAJECTORIES');
|
|
calls.push({ mode: isFailure ? 'failure' : 'success' });
|
|
const edit = isFailure
|
|
? { op: 'add', anchor: 'Failures', content: 'C1' }
|
|
: { op: 'add', anchor: 'Successes', content: 'C2' };
|
|
return makeChatResult(JSON.stringify({ edits: [edit] }));
|
|
};
|
|
|
|
const result = await runReflect({
|
|
skillBodyText: '# Test',
|
|
successes: [makeScored('s-1', 1.0)],
|
|
failures: [makeScored('f-1', 0.0)],
|
|
rejected: [],
|
|
optimizerModel: 'anthropic:claude-opus-4-7',
|
|
chatFn,
|
|
});
|
|
|
|
expect(calls).toHaveLength(2);
|
|
expect(calls.map((c) => c.mode).sort()).toEqual(['failure', 'success']);
|
|
expect(result.failureEdits).toHaveLength(1);
|
|
expect(result.failureEdits[0]).toMatchObject({ anchor: 'Failures' });
|
|
expect(result.successEdits).toHaveLength(1);
|
|
expect(result.successEdits[0]).toMatchObject({ anchor: 'Successes' });
|
|
// Token usage is additive across the two calls.
|
|
expect(result.usage.input_tokens).toBe(200);
|
|
expect(result.usage.output_tokens).toBe(40);
|
|
expect(result.usage.cache_read_tokens).toBe(10);
|
|
expect(result.usage.cache_creation_tokens).toBe(2);
|
|
expect(result.errors).toHaveLength(0);
|
|
});
|
|
|
|
test('empty failures skips failure call; non-empty successes still fires success', async () => {
|
|
const callModes: string[] = [];
|
|
const chatFn = async (opts: ChatOpts): Promise<ChatResult> => {
|
|
callModes.push((opts.system ?? '').includes('FAILURE') ? 'failure' : 'success');
|
|
return makeChatResult(JSON.stringify({ edits: [{ op: 'delete', target: 'x' }] }));
|
|
};
|
|
|
|
const result = await runReflect({
|
|
skillBodyText: '# Test',
|
|
successes: [makeScored('s-1', 1.0)],
|
|
failures: [],
|
|
rejected: [],
|
|
optimizerModel: 'anthropic:claude-opus-4-7',
|
|
chatFn,
|
|
});
|
|
|
|
expect(callModes).toEqual(['success']);
|
|
expect(result.failureEdits).toHaveLength(0);
|
|
expect(result.successEdits).toHaveLength(1);
|
|
});
|
|
|
|
test('empty successes skips success call; non-empty failures still fires failure', async () => {
|
|
const callModes: string[] = [];
|
|
const chatFn = async (opts: ChatOpts): Promise<ChatResult> => {
|
|
callModes.push((opts.system ?? '').includes('FAILURE') ? 'failure' : 'success');
|
|
return makeChatResult(JSON.stringify({ edits: [{ op: 'add', anchor: 'H', content: 'C' }] }));
|
|
};
|
|
|
|
const result = await runReflect({
|
|
skillBodyText: '# Test',
|
|
successes: [],
|
|
failures: [makeScored('f-1', 0.0)],
|
|
rejected: [],
|
|
optimizerModel: 'anthropic:claude-opus-4-7',
|
|
chatFn,
|
|
});
|
|
|
|
expect(callModes).toEqual(['failure']);
|
|
expect(result.failureEdits).toHaveLength(1);
|
|
expect(result.successEdits).toHaveLength(0);
|
|
});
|
|
|
|
test('both empty: neither call fires (cost-conscious short-circuit)', async () => {
|
|
let callCount = 0;
|
|
const chatFn = async (): Promise<ChatResult> => {
|
|
callCount += 1;
|
|
return makeChatResult('{"edits":[]}');
|
|
};
|
|
|
|
const result = await runReflect({
|
|
skillBodyText: '# Test',
|
|
successes: [],
|
|
failures: [],
|
|
rejected: [],
|
|
optimizerModel: 'anthropic:claude-opus-4-7',
|
|
chatFn,
|
|
});
|
|
|
|
expect(callCount).toBe(0);
|
|
expect(result.failureEdits).toHaveLength(0);
|
|
expect(result.successEdits).toHaveLength(0);
|
|
});
|
|
|
|
test('one mode throws: error recorded, other mode still produces edits', async () => {
|
|
const chatFn = async (opts: ChatOpts): Promise<ChatResult> => {
|
|
const isFailure = (opts.system ?? '').includes('FAILURE');
|
|
if (isFailure) throw new Error('rate_limit on failure call');
|
|
return makeChatResult(JSON.stringify({ edits: [{ op: 'add', anchor: 'OK', content: 'C' }] }));
|
|
};
|
|
|
|
const result = await runReflect({
|
|
skillBodyText: '# Test',
|
|
successes: [makeScored('s-1', 1.0)],
|
|
failures: [makeScored('f-1', 0.0)],
|
|
rejected: [],
|
|
optimizerModel: 'anthropic:claude-opus-4-7',
|
|
chatFn,
|
|
});
|
|
|
|
expect(result.failureEdits).toEqual([]);
|
|
expect(result.successEdits).toHaveLength(1);
|
|
expect(result.successEdits[0]).toMatchObject({ anchor: 'OK' });
|
|
expect(result.errors).toHaveLength(1);
|
|
expect(result.errors[0]).toContain('reflect_failure_failed');
|
|
expect(result.errors[0]).toContain('rate_limit');
|
|
});
|
|
|
|
test('rejected-buffer context flows into the user message (anti-bias)', async () => {
|
|
let observedUserMsg = '';
|
|
const chatFn = async (opts: ChatOpts): Promise<ChatResult> => {
|
|
const userMsg = opts.messages[0]?.content;
|
|
if (typeof userMsg === 'string') observedUserMsg = userMsg;
|
|
return makeChatResult('{"edits":[]}');
|
|
};
|
|
|
|
await runReflect({
|
|
skillBodyText: '# Test',
|
|
successes: [],
|
|
failures: [makeScored('f-1', 0.0)],
|
|
rejected: [
|
|
{
|
|
key: 'k1',
|
|
skill_sha8: 'deadbeef',
|
|
edits: [{ op: 'add', anchor: 'X', content: 'Y' }],
|
|
reason: 'validation_gate_below_baseline',
|
|
ts: '2026-01-01T00:00:00Z',
|
|
},
|
|
],
|
|
optimizerModel: 'anthropic:claude-opus-4-7',
|
|
chatFn,
|
|
});
|
|
|
|
expect(observedUserMsg).toContain('PREVIOUSLY REJECTED EDITS');
|
|
expect(observedUserMsg).toContain('validation_gate_below_baseline');
|
|
});
|
|
});
|