Files
waggle-os/benchmarks/gepa/scripts/faza-1/run-mutation-oracle.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

439 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env tsx
/**
* GEPA Faza 1 — mutation oracle runner.
*
* Per launch decision §G step 7 + manifest v7 §gepa.mutation_oracle +
* §mutation_oracle_design + Amendment 2 §4 (forked Qwen vs non-Qwen templates).
*
* For each of 5 shapes, generate 2 mutation candidates via Opus 4.7. Each
* candidate is validated via mutation-validator.ts (cell-semantic preservation).
* Output: 10 candidate files at packages/agent/src/prompt-shapes/gepa-evolved/<shape>-gen1-v<N>.ts
*
* Approach:
* - Use JSON-mode response_format (Amendment 4 lesson) for reliable parsing
* - Opus outputs JSON with the 5 method body strings + new evidence_link
* - Runner assembles TS file from fixed template (preserves cell semantics by construction)
* - Validator confirms types.ts + MULTI_STEP_ACTION_CONTRACT SHAs unchanged
*
* Cost projection: 10 calls × ~$0.15 = ~$1.50
*
* Failure handling per brief §5: 2 consecutive invalid mutations from oracle
* → halt-and-PM.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { validateCandidate, type ValidatorVerdict } from '../../src/faza-1/mutation-validator.js';
import { classifyShape, type TemplateClass } from '../../src/faza-1/mutation-oracle-fork.js';
import { type ShapeName, QWEN_TARGETED_SHAPES } from '../../src/faza-1/types.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, '../../../..');
const PROMPT_SHAPES_DIR = path.join(REPO_ROOT, 'packages/agent/src/prompt-shapes');
const TYPES_FILE = path.join(PROMPT_SHAPES_DIR, 'types.ts');
const GEPA_EVOLVED_DIR = path.join(PROMPT_SHAPES_DIR, 'gepa-evolved');
const ORACLE_LOG = path.join(REPO_ROOT, 'benchmarks/results/gepa-faza1/gen-1/mutation-oracle-run.log');
const OUT_MANIFEST = path.join(REPO_ROOT, 'benchmarks/results/gepa-faza1/gen-1/mutation-oracle-manifest.json');
const LITELLM_URL = process.env.LITELLM_URL ?? 'http://localhost:4000';
const ORACLE_MODEL = 'claude-opus-4-7';
const ORACLE_MAX_TOKENS = 8000;
const N_MUTATIONS_PER_SHAPE = 2;
const SHAPES: ShapeName[] = ['claude', 'qwen-thinking', 'qwen-non-thinking', 'gpt', 'generic-simple'];
const PHASE_4_3_FAILURE_MODES = `Top T2 categories from Phase 4.3 verdict (decisions/2026-04-28-phase-4-3-rescore-delta-report.md):
1. unsupported-specifics (10 of 26 T2 hits): hallucinated specifics or overreach beyond materials
2. missed / didn't-consider / shallow (9 of 26 T2 hits): incomplete coverage of source documents
3. conflation / weak-synthesis (5 of 26 T2 hits): risks blended together rather than separated
4. wrong-entity / off-topic (sparse): minor framing errors
Phase 4.5 retrieval-engagement signal: Qwen retrieves 1.33×/task vs Opus 2.33×/task on byte-identical tool surface. H3 corpus NULL-baseline replicated this (mean retrievals = 1.05).`;
// ── Logging ────────────────────────────────────────────────────────────────
function log(msg: string): void {
const line = `[${new Date().toISOString()}] ${msg}\n`;
try { fs.appendFileSync(ORACLE_LOG, line); } catch { /* dir may not exist yet */ }
process.stderr.write(line);
}
// ── Baseline shape inspection (extract metadata for prompt) ────────────────
interface BaselineShapeMetadata {
description: string;
modelClass: string;
defaultThinking: boolean;
defaultMaxTokens: number;
shapeFileContent: string; // full file text
}
function loadBaselineShape(shapeName: ShapeName): BaselineShapeMetadata {
const shapeFile = path.join(PROMPT_SHAPES_DIR, `${shapeName}.ts`);
const content = fs.readFileSync(shapeFile, 'utf-8');
const description = content.match(/description: '([^']+)'/)?.[1] ?? '';
const modelClass = content.match(/modelClass: '([^']+)'/)?.[1] ?? shapeName;
const defaultThinking = content.match(/defaultThinking: (true|false|undefined)/)?.[1] === 'true';
const defaultMaxTokens = Number(content.match(/defaultMaxTokens: (\d+)/)?.[1] ?? 4096);
return { description, modelClass, defaultThinking, defaultMaxTokens, shapeFileContent: content };
}
// ── Oracle prompt (JSON-mode) ──────────────────────────────────────────────
function buildOraclePrompt(shapeName: ShapeName, baseline: BaselineShapeMetadata, mutationIdx: number): string {
const cls: TemplateClass = classifyShape(shapeName);
const isQwen = QWEN_TARGETED_SHAPES.has(shapeName);
const qwenGuidance = `For Qwen-targeted shape mutation:
- Emphasize multi-turn retrieval over single-shot retrieval. Phrase like "Continue retrieving until you have evidence from at least 2 distinct queries before finalizing."
- Add anti-premature-finalization scaffolding. Phrase like "Before finalizing, ask: what gap in evidence remains? Issue another retrieval if any gap exists."
- Encourage iterative refinement of retrieval queries based on prior turn results.
- Goal: push mean retrieval_calls per task from current 1.0 baseline toward >= 1.5 (escape Amendment 2 penalty zone) and ideally >= 2.0 (Opus parity proxy).`;
const nonQwenGuidance = `For non-Qwen shape mutation:
- Standard mutation guidance per brief §3.3 — evolve reasoning scaffold, planning step structure, chain-of-thought triggers.
- Restructure implicit reasoning prompts (e.g., "think step by step" variants, planning bullets).
- Refine where the model is prompted to articulate reasoning before producing output.
- Improve multi-step task decomposition explicitness.`;
return `You are an expert prompt engineer. Generate ONE mutated variant of the prompt-shape below, evolving reasoning scaffold + retrieval-engagement guidance while preserving cell semantics.
## Target shape
- Name: ${shapeName}
- Class: ${cls}
- Mutation variant index: ${mutationIdx} (you are generating mutation #${mutationIdx} of 2 for this shape)
## Baseline metadata (LOCKED — do NOT change these)
- description: ${baseline.description}
- modelClass: ${baseline.modelClass}
- defaultThinking: ${baseline.defaultThinking}
- defaultMaxTokens: ${baseline.defaultMaxTokens}
## Phase 4.3 + Phase 4.5 failure modes to address
${PHASE_4_3_FAILURE_MODES}
## Mutation guidance
${isQwen ? qwenGuidance : nonQwenGuidance}
## Cell semantic boundaries (LOCKED — violation = REJECTED candidate)
You may NOT modify:
- The MULTI_STEP_ACTION_CONTRACT constant (lives in types.ts; bytes are SHA-pinned)
- The JSON action contract format ({"action": "retrieve" | "finalize", ...})
- Task framing (persona/question/materials section labels)
- Imports block
- Locked metadata fields above
You MAY modify:
- The 5 method bodies (string-building only): systemPromptSolo, systemPromptMultiStep, soloUserPrompt, multiStepKickoffUserPrompt, retrievalInjectionUserPrompt
- The evidence_link metadata (you MUST update to point to GEPA Gen 1 results: "benchmarks/results/gepa-faza1/gen-1/mutation-oracle-run.log + Phase 4.5 + Amendment 2 §3 retrieval-engagement bonus")
## Baseline shape file (your input)
\`\`\`typescript
${baseline.shapeFileContent}
\`\`\`
## Your output: JSON object
Output a single JSON object with these fields. Each method body field should be a TypeScript expression that evaluates to a string (the prompt text). Use the same approach as the baseline (e.g., array.join('\\n')). Variable references like \${persona}, \${question}, \${maxSteps}, \${maxRetrievalsPerStep}, \${input.persona}, etc. must be preserved verbatim where the baseline used them.
\`\`\`json
{
"evidenceLink": "<updated evidence_link string referencing Gen 1 results + Phase 4.5/Amendment 2>",
"systemPromptSolo": "<TypeScript expression returning system prompt for !isMultiStep — typically a join of strings or template literal; reference {persona}>",
"systemPromptMultiStep": "<TypeScript expression returning system prompt for isMultiStep — must reference MULTI_STEP_ACTION_CONTRACT verbatim, {persona}, {question}, {maxSteps}, {maxRetrievalsPerStep}>",
"soloUserPrompt": "<TypeScript expression returning user prompt with {input.persona}, {input.materials}, {input.question}>",
"multiStepKickoffUserPrompt": "<TypeScript expression returning kickoff user message; baseline uses 'Begin. Output your first action JSON now.' — your variant should request engagement scaffolding for Qwen shapes>",
"retrievalInjectionUserPrompt": "<TypeScript expression returning retrieval-injection user message with {input.query}, {input.resultCount}, {input.results}>"
}
\`\`\`
CRITICAL: each field's value must be a STRING containing valid TypeScript code that, when wrapped in \`return (\${value})\`, would compile + return a string. The simplest valid pattern is template literals or .join('\\n') over an array of strings.
Output ONLY the JSON object. No prose. No code fences.`;
}
// ── LLM call (JSON-mode, no temperature per Amendment 4 lesson) ────────────
interface OracleCallResult {
content: string;
inTokens: number;
outTokens: number;
costUsd: number;
latencyMs: number;
error?: string;
}
async function callOpusOracle(prompt: string): Promise<OracleCallResult> {
const masterKey = process.env.LITELLM_MASTER_KEY;
if (!masterKey) throw new Error('LITELLM_MASTER_KEY env not set');
const payload = {
model: ORACLE_MODEL,
messages: [{ role: 'user', content: prompt }],
max_tokens: ORACLE_MAX_TOKENS,
response_format: { type: 'json_object' },
// temperature omitted per Amendment 4 (Anthropic deprecates with JSON mode)
};
const started = Date.now();
let lastErr: string | undefined;
for (let attempt = 0; attempt < 2; attempt++) {
try {
const resp = await fetch(`${LITELLM_URL}/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${masterKey}` },
body: JSON.stringify(payload),
});
const d: any = await resp.json();
if ('error' in d) {
lastErr = String(d.error?.message ?? JSON.stringify(d.error)).slice(0, 200);
if (attempt < 1) await new Promise(r => setTimeout(r, 1500));
continue;
}
const content = d.choices?.[0]?.message?.content ?? '';
const usage = d.usage ?? {};
const inTok = usage.prompt_tokens ?? 0;
const outTok = usage.completion_tokens ?? 0;
const costUsd = (inTok * 15.0 + outTok * 75.0) / 1_000_000;
return { content, inTokens: inTok, outTokens: outTok, costUsd, latencyMs: Date.now() - started };
} catch (e) {
lastErr = `${(e as Error).name}: ${(e as Error).message}`.slice(0, 200);
if (attempt < 1) await new Promise(r => setTimeout(r, 1500));
}
}
return { content: '', inTokens: 0, outTokens: 0, costUsd: 0, latencyMs: Date.now() - started, error: lastErr };
}
// ── JSON parsing + TS file assembly ────────────────────────────────────────
interface MutationFields {
evidenceLink: string;
systemPromptSolo: string;
systemPromptMultiStep: string;
soloUserPrompt: string;
multiStepKickoffUserPrompt: string;
retrievalInjectionUserPrompt: string;
}
function parseOracleOutput(content: string): MutationFields | { error: string } {
let s = content.trim();
if (s.startsWith('```')) s = s.replace(/^```[a-z]*\n?/, '').replace(/```\s*$/, '');
const firstBrace = s.indexOf('{');
const lastBrace = s.lastIndexOf('}');
if (firstBrace < 0 || lastBrace < 0) {
return { error: `no JSON object found in output (length=${content.length})` };
}
try {
const obj = JSON.parse(s.slice(firstBrace, lastBrace + 1));
const required = ['evidenceLink', 'systemPromptSolo', 'systemPromptMultiStep', 'soloUserPrompt', 'multiStepKickoffUserPrompt', 'retrievalInjectionUserPrompt'];
for (const k of required) {
if (typeof obj[k] !== 'string' || obj[k].length === 0) {
return { error: `field "${k}" missing or empty` };
}
}
return obj as MutationFields;
} catch (e) {
return { error: `JSON parse failed: ${(e as Error).message}` };
}
}
function buildShapeFile(shapeName: ShapeName, baseline: BaselineShapeMetadata, fields: MutationFields, mutationIdx: number): string {
const exportName = `${shapeName.replace(/-([a-z])/g, (_, c) => c.toUpperCase())}Gen1V${mutationIdx}Shape`;
const tsName = `${shapeName}-gen1-v${mutationIdx}`;
return `/**
* GEPA Faza 1 — Gen 1 mutation #${mutationIdx} of ${shapeName}.
*
* Generated by Opus 4.7 mutation oracle per Amendment 2 §4 forked template
* (${classifyShape(shapeName)} branch). Cell-semantic boundaries preserved
* via mutation-validator.ts SHA pins.
*
* Evidence: ${fields.evidenceLink}
*
* Baseline anchor: packages/agent/src/prompt-shapes/${shapeName}.ts (manifest v7
* §gepa.mutation_validator.baseline_shape_shas[${shapeName}.ts]).
*/
import {
type PromptShape,
type SystemPromptInput,
type SoloUserPromptInput,
type MultiStepKickoffInput,
type RetrievalInjectionInput,
MULTI_STEP_ACTION_CONTRACT,
} from '../types.js';
export const ${exportName}: PromptShape = {
name: '${tsName}',
metadata: {
description: '${baseline.description.replace(/'/g, "\\'")}',
modelClass: '${baseline.modelClass}',
evidence_link: ${JSON.stringify(fields.evidenceLink)},
defaultThinking: ${baseline.defaultThinking},
defaultMaxTokens: ${baseline.defaultMaxTokens},
},
systemPrompt(input: SystemPromptInput): string {
const { persona, question, isMultiStep, maxSteps = 5, maxRetrievalsPerStep = 8 } = input;
if (!isMultiStep) {
return ${fields.systemPromptSolo};
}
return ${fields.systemPromptMultiStep};
},
soloUserPrompt(input: SoloUserPromptInput): string {
return ${fields.soloUserPrompt};
},
multiStepKickoffUserPrompt(_input: MultiStepKickoffInput): string {
return ${fields.multiStepKickoffUserPrompt};
},
retrievalInjectionUserPrompt(input: RetrievalInjectionInput): string {
return ${fields.retrievalInjectionUserPrompt};
},
};
`;
}
// ── Validate via runtime sanity (compile check) + structural check ─────────
function validateAssembledFile(filepath: string, baselineShapeName: ShapeName): { valid: boolean; reason?: string } {
// Check file parses as TypeScript by attempting a require-style import
// For Faza 1 simplicity, just check that the file:
// 1. Imports from types.js (mutation-validator's import preservation check)
// 2. Contains the required locked metadata fields
// 3. Exports a single PromptShape
// 4. Doesn't break the cell-semantic boundary (types.ts/MULTI_STEP_ACTION_CONTRACT SHAs unchanged)
// The mutation-validator.ts does the SHA checks; we use it via validateCandidate.
const verdict: ValidatorVerdict = validateCandidate({
candidateShapeFilePath: filepath,
baselineShapeName: `${baselineShapeName}.ts` as any,
typesFilePath: TYPES_FILE,
expectShapeDiff: true,
});
if (!verdict.valid) {
return { valid: false, reason: `validator violations: ${verdict.violations.map(v => `${v.category}: ${v.detail}`).join('; ')}` };
}
return { valid: true };
}
// ── Main: generate 10 mutations ────────────────────────────────────────────
interface MutationManifestEntry {
shape: ShapeName;
mutationIdx: number;
filename: string;
costUsd: number;
latencyMs: number;
tsName: string;
validatorVerdict: 'valid' | 'invalid_after_retry';
error?: string;
}
async function main(): Promise<void> {
fs.mkdirSync(GEPA_EVOLVED_DIR, { recursive: true });
fs.mkdirSync(path.dirname(ORACLE_LOG), { recursive: true });
if (!fs.existsSync(ORACLE_LOG)) fs.writeFileSync(ORACLE_LOG, '');
log(`[start] mutation oracle for ${SHAPES.length} shapes × ${N_MUTATIONS_PER_SHAPE} mutations`);
const manifest: MutationManifestEntry[] = [];
let cumulativeCost = 0;
let consecutiveInvalidGlobal = 0;
for (const shapeName of SHAPES) {
const baseline = loadBaselineShape(shapeName);
log(`[${shapeName}] baseline loaded; modelClass=${baseline.modelClass} description="${baseline.description.slice(0, 60)}..."`);
for (let mutIdx = 1; mutIdx <= N_MUTATIONS_PER_SHAPE; mutIdx++) {
const tsName = `${shapeName}-gen1-v${mutIdx}`;
const outFile = path.join(GEPA_EVOLVED_DIR, `${tsName}.ts`);
if (fs.existsSync(outFile)) {
log(`[${tsName}] already exists; skipping`);
manifest.push({ shape: shapeName, mutationIdx: mutIdx, filename: outFile, costUsd: 0, latencyMs: 0, tsName, validatorVerdict: 'valid' });
continue;
}
// Try once; if invalid, retry once with structural feedback. After 2 fails → mark invalid_after_retry.
let valid = false;
let totalCost = 0;
let totalLatency = 0;
let errMsg: string | undefined;
for (let attempt = 0; attempt < 2 && !valid; attempt++) {
const prompt = buildOraclePrompt(shapeName, baseline, mutIdx);
log(`[${tsName}] oracle call attempt ${attempt + 1}; prompt_len=${prompt.length}c`);
const llm = await callOpusOracle(prompt);
totalCost += llm.costUsd;
totalLatency += llm.latencyMs;
if (llm.error) {
errMsg = `LLM error: ${llm.error}`;
log(`[${tsName}] ${errMsg}`);
continue;
}
const parsed = parseOracleOutput(llm.content);
if ('error' in parsed) {
errMsg = `parse error: ${parsed.error}`;
log(`[${tsName}] ${errMsg}; first 200c: ${llm.content.slice(0, 200)}`);
continue;
}
const tsContent = buildShapeFile(shapeName, baseline, parsed, mutIdx);
fs.writeFileSync(outFile, tsContent, 'utf-8');
const v = validateAssembledFile(outFile, shapeName);
if (v.valid) {
valid = true;
log(`[${tsName}] OK; cost=$${llm.costUsd.toFixed(4)}; latency=${llm.latencyMs}ms; file=${path.basename(outFile)}`);
} else {
errMsg = `validator failed: ${v.reason}`;
log(`[${tsName}] ${errMsg}`);
fs.unlinkSync(outFile);
}
}
cumulativeCost += totalCost;
manifest.push({
shape: shapeName, mutationIdx: mutIdx, filename: outFile,
costUsd: totalCost, latencyMs: totalLatency, tsName,
validatorVerdict: valid ? 'valid' : 'invalid_after_retry',
error: valid ? undefined : errMsg,
});
if (valid) {
consecutiveInvalidGlobal = 0;
} else {
consecutiveInvalidGlobal++;
if (consecutiveInvalidGlobal >= 2) {
log(`[HALT] 2 consecutive invalid mutations from oracle (per brief §5) — stopping`);
break;
}
}
}
if (consecutiveInvalidGlobal >= 2) break;
}
fs.writeFileSync(OUT_MANIFEST, JSON.stringify({
totalMutationsAttempted: manifest.length,
totalValid: manifest.filter(m => m.validatorVerdict === 'valid').length,
totalInvalid: manifest.filter(m => m.validatorVerdict !== 'valid').length,
cumulativeCostUsd: +cumulativeCost.toFixed(6),
entries: manifest,
completedAtIso: new Date().toISOString(),
}, null, 2));
log(`[done] ${manifest.length} mutations attempted; ${manifest.filter(m => m.validatorVerdict === 'valid').length} valid; cumulative $${cumulativeCost.toFixed(4)}`);
}
main().catch(e => { console.error('FATAL:', e); process.exit(2); });