Files
ruflo/scripts/eval-learning-loop-corpus.mjs
T
rUvandr 243bc57f0b feat(learning): lessons say where the fix goes; reworded tasks learn together (ADR-511) (#4603)
* feat(learning): lessons say where the fix goes; reworded tasks learn together (ADR-511)

The §7 evaluation (#4581) showed three limits on what the verified-outcome
loop can learn:

- a lesson named an agent, nothing the model can act on;
- tasks naming different files merged into one lesson;
- the same task worded differently never learned (15 reworded verified
  successes left 15 patterns at one success each, zero lessons).

Changes:
- mods: a verified turn records its screened edited source files; a lesson
  names the three its passing fixes edited most and leads with them; pick()
  weighs rare keywords (IDF) and drops matches far below the best.
  ruflo-mods 0.13.0.
- cli: files are counted per pattern (successes only) into learned-lessons.json;
  SONA takes them as pattern anchors (never keywords), so reworded tasks that
  fixed the same file reinforce one pattern; tasks naming disjoint paths never
  merge. Lesson building moves to verified-lessons.ts.
- eval: the harness gains --ablate, --test-index, --concurrency, --lessons,
  --save-lessons, --eval-sample, --calibrate and --max-turns;
  scripts/eval-learning-loop-corpus.mjs writes a validated 15+30-task corpus.

Proved offline (tests): 15 reworded prompts form 5 lessons, up from 0; the
first run's prompts form 5 lessons, up from 1; the right lesson ranks first
for 30 of 30 eval prompts, with 1.07 lessons shown per prompt.

Paid pilots (haiku, about $6.80) are directional and did not reach the gate:
- 8-turn cap: off 2/10, on 4/10, lessons without files 1/10;
- uncapped: 6/6 vs 6/6, cost within 1%.
ADR-511 §5 has the details.

Co-Authored-By: RuFlo <ruv@ruv.net>

* docs(adr): ADR-511 cites PR #4603

Co-Authored-By: RuFlo <ruv@ruv.net>

---------

Co-authored-by: r <r@x>
2026-10-30 12:12:42 -04:00

500 lines
22 KiB
JavaScript

#!/usr/bin/env node
/**
* ADR-511 §5: the corpus for scripts/eval-learning-loop.mjs ($0, no model call):
* node scripts/eval-learning-loop-corpus.mjs --out <dir> [--train 3] [--eval 6]
* One small Node project ("the shop"), five features. Each feature's public module (src/money.js, src/slug.js, ...)
* resolves its core step through src/registry.js and config/registry.json, so the code that decides the behaviour
* lives in a file whose name says little (src/ext/half-even.js for money rounding) next to an unused module whose name
* says a lot (src/ext/round.js). That is the kind of project-specific "where the fix goes" knowledge a lesson can
* carry and a prompt does not: the prompts describe a symptom and name no file.
*
* A fixture is the whole project with ONE bug planted in one feature's real core file. Per feature, the first
* --train mutations form the training split and the next --eval the evaluation split, so no eval fixture is a train
* fixture. Each task's check is a hidden copy of all five feature specs, run from outside the fixture, so a run that
* edits a visible test, or breaks another feature, does not pass. Validated as written: the clean project passes
* every hidden spec, and every planted bug fails its own feature's spec (one that does not is an error).
*/
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
const REGISTRY_JSON = { rounding: 'halfEven', fold: 'fold', window: 'span', tokenize: 'lexer', combine: 'overlay' };
const SHARED = {
'package.json': JSON.stringify({ name: 'shop', version: '1.0.0', private: true, type: 'module', scripts: { test: 'node --test' } }, null, 2) + '\n',
'README.md': '# shop\n\nInvoices, catalogue slugs, listing pages, CSV imports and layered config.\n',
'config/registry.json': JSON.stringify(REGISTRY_JSON, null, 2) + '\n',
'src/registry.js': `import { readFileSync } from 'node:fs';
import * as ext from './ext/index.js';
const table = JSON.parse(readFileSync(new URL('../config/registry.json', import.meta.url), 'utf8'));
/** The implementation configured for a step. */
export function get(step) {
const name = table[step];
const impl = name && ext[name];
if (typeof impl !== 'function') throw new Error(\`no implementation for \${step}\`);
return impl;
}
`,
'src/ext/index.js': `export { default as halfEven } from './half-even.js';
export { default as round } from './round.js';
export { default as fold } from './fold.js';
export { default as translit } from './translit.js';
export { default as span } from './span.js';
export { default as pages } from './pages.js';
export { default as lexer } from './lexer.js';
export { default as csvSplit } from './csv-split.js';
export { default as overlay } from './overlay.js';
export { default as deepMerge } from './deep-merge.js';
`,
// Unused (not in config/registry.json): kept for "older callers".
'src/ext/round.js': `/** Rounds half away from zero to \`decimals\` places. */
export default function round(value, decimals) {
const f = 10 ** decimals;
return Math.sign(value) * Math.round(Math.abs(value) * f) / f;
}
`,
'src/ext/translit.js': `const TABLE = { 'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss' };
/** German-style transliteration. */
export default function translit(s) {
return [...s].map(c => TABLE[c] ?? c).join('');
}
`,
'src/ext/pages.js': `/** Every page number from 1 to \`count\`. */
export default function pages(count) {
return Array.from({ length: Math.max(0, count) }, (_, i) => i + 1);
}
`,
'src/ext/csv-split.js': `/** Splits a CSV line on the separator (no quoting support). */
export default function csvSplit(line, sep = ',') {
return line.split(sep);
}
`,
'src/ext/deep-merge.js': `/** Recursively merges plain objects. */
export default function deepMerge(a, b) {
const out = { ...a };
for (const k of Object.keys(b)) out[k] = typeof b[k] === 'object' && b[k] && typeof a[k] === 'object' ? deepMerge(a[k], b[k]) : b[k];
return out;
}
`,
'src/money.js': `import { get } from './registry.js';
const CURRENCIES = { USD: { symbol: '$', decimals: 2 }, EUR: { symbol: '€', decimals: 2 }, JPY: { symbol: '¥', decimals: 0 }, KWD: { symbol: 'KD ', decimals: 3 } };
/** "$1,234.56": rounded with the configured rounding step, grouped by thousands. */
export function formatMoney(amount, currency) {
const c = CURRENCIES[currency];
if (!c) throw new Error(\`unknown currency \${currency}\`);
const rounded = get('rounding')(Math.abs(amount), c.decimals);
const [int, frac] = rounded.toFixed(c.decimals).split('.');
const grouped = int.replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',');
return \`\${amount < 0 && rounded !== 0 ? '-' : ''}\${c.symbol}\${grouped}\${frac ? '.' + frac : ''}\`;
}
`,
'src/slug.js': `import { get } from './registry.js';
/** A URL slug: folded to ASCII, lower case, runs of anything else as one dash. */
export function slugify(title) {
return get('fold')(title).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
`,
'src/listing.js': `import { get } from './registry.js';
/** The page links and item range for page \`page\` of \`total\` items, \`size\` per page, at most \`width\` links. */
export function paginate(total, page, size, width = 5) {
const count = Math.ceil(total / size);
const current = Math.min(Math.max(1, page), Math.max(1, count));
const from = total === 0 ? 0 : (current - 1) * size + 1;
const to = Math.min(total, current * size);
return { count, current, from, to, links: get('window')(current, count, width) };
}
`,
'src/importer.js': `import { get } from './registry.js';
/** Rows of fields; CRLF or LF line ends, a trailing newline adds no row. */
export function parseCsv(text, { sep = ',' } = {}) {
const lines = text.split(/\\r?\\n/);
if (lines.at(-1) === '') lines.pop();
return lines.map(line => get('tokenize')(line, sep));
}
`,
'src/settings.js': `import { get } from './registry.js';
/** Defaults overlaid by each layer in turn; no layer is changed. */
export function loadConfig(defaults, ...layers) {
return layers.reduce((acc, layer) => get('combine')(acc, layer), defaults);
}
`,
};
/** Each feature: its real core file (correct), the planted bugs (find → replace), the spec, and symptom prompts. */
const FEATURES = [
{
id: 'money',
file: 'src/ext/half-even.js',
source: `/** Rounds to \`decimals\` places, ties to the even neighbour (banker's rounding). */
export default function halfEven(value, decimals) {
const f = 10 ** decimals;
const n = Math.round(value * f * 1e6) / 1e6;
const floor = Math.floor(n);
const diff = n - floor;
let r;
if (diff > 0.5) r = floor + 1;
else if (diff < 0.5) r = floor;
else r = floor % 2 === 0 ? floor : floor + 1;
return r / f;
}
`,
bugs: [
['if (diff > 0.5)', 'if (diff >= 0.5)'],
['floor % 2 === 0 ? floor', 'floor % 2 === 1 ? floor'],
['10 ** decimals', '10 ** (decimals + 1)'],
['else if (diff < 0.5) r = floor;', 'else if (diff < 0.5) r = floor + (diff > 0.25 ? 1 : 0);'],
['return r / f;', 'return Math.trunc(r / f);'],
['* 1e6) / 1e6', '* 1e1) / 1e1'],
[': floor + 1;\n return', ': floor;\n return'],
['if (diff > 0.5) r = floor + 1;', 'if (diff > 0.5) r = floor + 2;'],
['const floor = Math.floor(n);', 'const floor = Math.floor(n) - (n > 1000 ? 1 : 0);'],
],
spec: `import { test } from 'node:test';
import assert from 'node:assert/strict';
import { formatMoney } from '$SRC/money.js';
test('money: plain amounts', () => {
assert.equal(formatMoney(0, 'USD'), '$0.00');
assert.equal(formatMoney(19.99, 'EUR'), '€19.99');
assert.equal(formatMoney(1234567.891, 'USD'), '$1,234,567.89');
assert.equal(formatMoney(-42.1, 'USD'), '-$42.10');
});
test('money: ties go to the even cent', () => {
assert.equal(formatMoney(2.5, 'JPY'), '¥2');
assert.equal(formatMoney(3.5, 'JPY'), '¥4');
assert.equal(formatMoney(0.125, 'USD'), '$0.12');
assert.equal(formatMoney(0.135, 'USD'), '$0.14');
assert.equal(formatMoney(1.0005, 'KWD'), 'KD 1.000');
});
test('money: non-ties round to nearest', () => {
assert.equal(formatMoney(2.6, 'JPY'), '¥3');
assert.equal(formatMoney(2.4, 'JPY'), '¥2');
assert.equal(formatMoney(10.126, 'USD'), '$10.13');
assert.equal(formatMoney(10.1251, 'USD'), '$10.13');
assert.equal(formatMoney(1234.5671, 'KWD'), 'KD 1,234.567');
});
`,
prompts: [
'Some invoice amounts are formatted with the wrong cents: formatMoney rounds certain prices incorrectly. Find the cause and fix it. Run npm test to confirm.',
'Customers report invoice totals that are a cent off. formatMoney is producing wrong amounts for some prices; please fix the rounding problem and run npm test.',
'formatMoney output is wrong for some invoice prices (rounding). Track down the bug, fix it, and confirm with npm test.',
],
},
{
id: 'slug',
file: 'src/ext/fold.js',
source: `const MAP = { 'ß': 'ss', 'æ': 'ae', 'ø': 'o', 'œ': 'oe', 'đ': 'd', 'ł': 'l', '&': ' and ' };
/** Folds text to plain ASCII letters: accents dropped, special letters spelled out. */
export default function fold(s) {
let out = '';
for (const ch of s.normalize('NFD')) {
if (/[\\u0300-\\u036f]/.test(ch)) continue;
const low = ch.toLowerCase();
out += MAP[low] ?? ch;
}
return out;
}
`,
bugs: [
["'ß': 'ss'", "'ß': 's'"],
["'æ': 'ae'", "'æ': 'a'"],
["'&': ' and '", "'&': 'and'"],
["s.normalize('NFD')", "s.normalize('NFC')"],
['\\u036f', '\\u0301'],
['MAP[low] ?? ch', 'MAP[ch] ?? ch'],
["'œ': 'oe'", "'œ': 'o'"],
["'ł': 'l', ", ''],
["'đ': 'd'", "'đ': 'dj'"],
],
spec: `import { test } from 'node:test';
import assert from 'node:assert/strict';
import { slugify } from '$SRC/slug.js';
test('slug: plain titles', () => {
assert.equal(slugify('Hello World'), 'hello-world');
assert.equal(slugify(' --Spring Sale 2026!! '), 'spring-sale-2026');
});
test('slug: accents are dropped', () => {
assert.equal(slugify('Crème Brûlée'), 'creme-brulee');
assert.equal(slugify('Ångström Ölmühle'), 'angstrom-olmuhle');
assert.equal(slugify('Ça̧ va'), 'ca-va');
});
test('slug: special letters are spelled out', () => {
assert.equal(slugify('Straße'), 'strasse');
assert.equal(slugify('Æther'), 'aether');
assert.equal(slugify('Œuvre'), 'oeuvre');
assert.equal(slugify('Ørsted'), 'orsted');
assert.equal(slugify('Łódź'), 'lodz');
assert.equal(slugify('Đakovo'), 'dakovo');
assert.equal(slugify('Rock & Roll'), 'rock-and-roll');
assert.equal(slugify('R&B Classics'), 'r-and-b-classics');
});
`,
prompts: [
'Catalogue URLs come out wrong for product titles with accents or special letters (slugify). Find the cause and fix it, then run npm test.',
'slugify produces bad catalogue slugs for some international product titles. Please fix the bug and confirm with npm test.',
'Some product page slugs are mangled when titles contain letters like ß or æ, or accents. Fix slugify and run npm test to check.',
],
},
{
id: 'listing',
file: 'src/ext/span.js',
source: `/** At most \`width\` consecutive page numbers around \`current\`, inside 1..\`count\`. */
export default function span(current, count, width) {
if (count <= 0) return [];
const half = Math.floor(width / 2);
let start = Math.max(1, current - half);
const end = Math.min(count, start + width - 1);
start = Math.max(1, end - width + 1);
const out = [];
for (let p = start; p <= end; p++) out.push(p);
return out;
}
`,
bugs: [
['Math.floor(width / 2)', 'Math.ceil(width / 2)'],
['start + width - 1', 'start + width'],
['end - width + 1)', 'end - width)'],
['p <= end', 'p < end'],
['Math.max(1, current - half)', 'Math.max(0, current - half)'],
['const half = Math.floor(width / 2);', 'const half = Math.floor(width / 2) + (width % 2 === 0 ? 1 : 0);'],
['Math.min(count, start', 'Math.max(count, start'],
[' start = Math.max(1, end - width + 1);\n', ''],
['let start = Math.max(1, current - half);', 'let start = Math.max(1, current - half + 1);'],
],
spec: `import { test } from 'node:test';
import assert from 'node:assert/strict';
import { paginate } from '$SRC/listing.js';
test('listing: item range', () => {
assert.deepEqual([paginate(95, 1, 10).from, paginate(95, 1, 10).to], [1, 10]);
assert.deepEqual([paginate(95, 10, 10).from, paginate(95, 10, 10).to], [91, 95]);
assert.equal(paginate(95, 1, 10).count, 10);
});
test('listing: page links stay centred and in range', () => {
assert.deepEqual(paginate(95, 1, 10).links, [1, 2, 3, 4, 5]);
assert.deepEqual(paginate(95, 2, 10).links, [1, 2, 3, 4, 5]);
assert.deepEqual(paginate(95, 5, 10).links, [3, 4, 5, 6, 7]);
assert.deepEqual(paginate(95, 10, 10).links, [6, 7, 8, 9, 10]);
assert.deepEqual(paginate(95, 6, 10, 4).links, [4, 5, 6, 7]);
});
test('listing: short and empty lists', () => {
assert.deepEqual(paginate(25, 2, 10).links, [1, 2, 3]);
assert.deepEqual(paginate(5, 1, 10).links, [1]);
assert.deepEqual(paginate(0, 1, 10).links, []);
});
`,
prompts: [
'The page links under product listings are wrong on some pages (paginate). Find the cause and fix it, then run npm test.',
'Listing pagination shows the wrong set of page-number links in some cases. Please fix paginate and confirm with npm test.',
'Users see odd page links in the listing pager: paginate returns the wrong links for some pages. Fix it and run npm test.',
],
},
{
id: 'importer',
file: 'src/ext/lexer.js',
source: `/** The fields of one CSV line: quoted fields may hold the separator, "" is a literal quote. */
export default function lexer(line, sep = ',') {
const out = [];
let cur = '';
let quoted = false;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (quoted) {
if (c === '"') {
if (line[i + 1] === '"') {
cur += '"';
i++;
} else quoted = false;
} else cur += c;
} else if (c === '"') quoted = true;
else if (c === sep) {
out.push(cur);
cur = '';
} else cur += c;
}
out.push(cur);
return out;
}
`,
bugs: [
["if (line[i + 1] === '\"')", "if (line[i + 2] === '\"')"],
["cur += '\"';\n i++;", "cur += '\"\"';\n i++;"],
['} else quoted = false;', '} else quoted = true;'],
['else if (c === sep)', "else if (c === ',')"],
[' out.push(cur);\n return out;', ' if (cur) out.push(cur);\n return out;'],
[' out.push(cur);\n cur = \'\';', ' out.push(cur.trim());\n cur = \'\';'],
[" cur = '';\n", " cur = cur.trim() ? '' : cur;\n"],
['for (let i = 0; i < line.length; i++)', 'for (let i = 0; i < line.length - 1; i++)'],
['} else cur += c;\n } else if', '} else if (c !== \' \') cur += c;\n } else if'],
],
spec: `import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseCsv } from '$SRC/importer.js';
test('importer: plain fields and line ends', () => {
assert.deepEqual(parseCsv('a,b,c\\n1,2,3\\n'), [['a', 'b', 'c'], ['1', '2', '3']]);
assert.deepEqual(parseCsv('a,b\\r\\nc,d'), [['a', 'b'], ['c', 'd']]);
assert.deepEqual(parseCsv('a,,b'), [['a', '', 'b']]);
assert.deepEqual(parseCsv('a,'), [['a', '']]);
assert.deepEqual(parseCsv(',x'), [['', 'x']]);
assert.deepEqual(parseCsv(' a , b'), [[' a ', ' b']]);
assert.deepEqual(parseCsv(' ,x'), [[' ', 'x']]);
});
test('importer: quoting', () => {
assert.deepEqual(parseCsv('"a,b",c'), [['a,b', 'c']]);
assert.deepEqual(parseCsv('"say ""hi""",x'), [['say "hi"', 'x']]);
assert.deepEqual(parseCsv('"",z'), [['', 'z']]);
assert.deepEqual(parseCsv('x,"two words"'), [['x', 'two words']]);
assert.deepEqual(parseCsv('"a ""b"" c"'), [['a "b" c']]);
});
test('importer: other separators', () => {
assert.deepEqual(parseCsv('a;b;"c;d"', { sep: ';' }), [['a', 'b', 'c;d']]);
assert.deepEqual(parseCsv('a\\tb', { sep: '\\t' }), [['a', 'b']]);
});
`,
prompts: [
'The product CSV import mis-reads some rows (parseCsv): fields come out split or merged wrongly. Find the cause and fix it, then run npm test.',
'parseCsv returns wrong fields for some import files, especially with quotes or other separators. Please fix it and confirm with npm test.',
'Our CSV importer garbles certain rows. Fix the parsing bug in parseCsv and run npm test.',
],
},
{
id: 'settings',
file: 'src/ext/overlay.js',
source: `const plain = v => v !== null && typeof v === 'object' && !Array.isArray(v);
/** \`top\` laid over \`base\`: plain objects merge, arrays and values replace, undefined is ignored; inputs never change. */
export default function overlay(base, top) {
const out = { ...base };
for (const [k, v] of Object.entries(top)) {
if (k === '__proto__' || k === 'constructor') continue;
if (v === undefined) continue;
out[k] = plain(v) && plain(base[k]) ? overlay(base[k], v) : Array.isArray(v) ? [...v] : v;
}
return out;
}
`,
bugs: [
["&& !Array.isArray(v);", ';'],
['if (v === undefined) continue;', 'if (v === null) continue;'],
["if (k === '__proto__' || k === 'constructor') continue;", ''],
['plain(v) && plain(base[k])', 'plain(v)'],
['Array.isArray(v) ? [...v] : v', 'v'],
['const out = { ...base };', 'const out = base;'],
['? overlay(base[k], v) :', '? { ...v } :'],
['if (v === undefined) continue;', 'if (v === undefined) { delete out[k]; continue; }'],
['plain(base[k]) ? overlay(base[k], v)', 'plain(base[k]) ? overlay(v, base[k])'],
],
spec: `import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadConfig } from '$SRC/settings.js';
test('settings: layers merge nested objects', () => {
const d = { db: { host: 'localhost', port: 5432, pool: { min: 1, max: 5 } }, debug: false };
assert.deepEqual(loadConfig(d, { db: { port: 6543 } }, { db: { pool: { max: 9 } }, debug: true }), { db: { host: 'localhost', port: 6543, pool: { min: 1, max: 9 } }, debug: true });
});
test('settings: arrays and nulls replace, undefined is ignored', () => {
assert.deepEqual(loadConfig({ tags: ['a', 'b'], x: 1, y: { z: 1 } }, { tags: ['c'], x: undefined, y: null }), { tags: ['c'], x: 1, y: null });
assert.deepEqual(loadConfig({ x: [1, 2] }, { x: { a: 1 } }), { x: { a: 1 } });
});
test('settings: no layer or default is changed', () => {
const d = { a: { b: 1 }, list: [1] };
const layer = { a: { c: 2 }, list: [2] };
const out = loadConfig(d, layer);
out.list.push(3);
out.a.d = 4;
assert.deepEqual(d, { a: { b: 1 }, list: [1] });
assert.deepEqual(layer, { a: { c: 2 }, list: [2] });
});
test('settings: a layer cannot reach the prototype', () => {
const out = loadConfig({}, JSON.parse('{"__proto__": {"polluted": true}, "ok": 1}'));
assert.equal(({}).polluted, undefined);
assert.equal(out.polluted, undefined);
assert.equal(out.ok, 1);
});
`,
prompts: [
'Layered settings come out wrong: loadConfig loses or mixes up values from some layers. Find the cause and fix it, then run npm test.',
'loadConfig gives the wrong merged config for some layer combinations. Please fix the bug and confirm with npm test.',
'Something is off with how config layers combine in loadConfig. Track down the bug, fix it, and run npm test.',
],
},
];
function args(argv) {
const out = { out: null, train: 3, eval: 6 };
const keys = { '--out': 'out', '--train': 'train', '--eval': 'eval' };
for (let i = 0; i < argv.length; i++) {
if (!keys[argv[i]]) throw new Error(`unknown argument: ${argv[i]}`);
out[keys[argv[i]]] = argv[i] === '--out' ? argv[++i] : Number(argv[++i]);
}
if (!out.out) throw new Error('--out <dir> is required');
return out;
}
function writeProject(dir, overrides = {}) {
const files = { ...SHARED };
for (const f of FEATURES) {
files[f.file] = f.source;
files[`test/${f.id}.test.mjs`] = f.spec.replace(/\$SRC/g, '../src');
}
Object.assign(files, overrides);
for (const [rel, body] of Object.entries(files)) {
mkdirSync(dirname(join(dir, rel)), { recursive: true });
writeFileSync(join(dir, rel), body);
}
}
/** Every hidden spec, importing from the fixture under test (the check's cwd). */
const hiddenSpec = (f) =>
`import { pathToFileURL } from 'node:url';\nimport { join } from 'node:path';\n` +
f.spec.replace(/import \{ (\w+) \} from '\$SRC\/([\w.]+)';/, (_, name, file) => `const { ${name} } = await import(pathToFileURL(join(process.cwd(), 'src', '${file}')).href);`);
const runSpecs = (cwd, specs) => spawnSync(process.execPath, ['--test', ...specs], { cwd, encoding: 'utf8', timeout: 60_000 }).status === 0;
function main() {
const a = args(process.argv.slice(2));
const root = resolve(a.out);
if (existsSync(root)) throw new Error(`${root} exists; pass a new directory`);
mkdirSync(join(root, 'hidden'), { recursive: true });
const specs = FEATURES.map(f => join(root, 'hidden', `${f.id}.test.mjs`));
FEATURES.forEach((f, i) => writeFileSync(specs[i], hiddenSpec(f)));
const scratch = mkdtempSync(join(tmpdir(), 'll-corpus-'));
writeProject(join(scratch, 'clean'));
const clean = runSpecs(join(scratch, 'clean'), specs);
rmSync(scratch, { recursive: true, force: true });
if (!clean) throw new Error('the clean project fails its hidden specs');
const tasks = { version: 1, train: [], eval: [] };
for (const f of FEATURES) {
if (f.bugs.length < a.train + a.eval) throw new Error(`${f.id}: ${f.bugs.length} bugs, need ${a.train + a.eval}`);
f.bugs.slice(0, a.train + a.eval).forEach(([find, replace], i) => {
if (!f.source.includes(find)) throw new Error(`${f.id} bug ${i}: pattern not found`);
const id = `${f.id}-${i}`;
const dir = join(root, 'fixtures', id);
writeProject(dir, { [f.file]: f.source.replace(find, replace) });
if (runSpecs(dir, [specs[FEATURES.indexOf(f)]])) throw new Error(`${id}: the planted bug does not fail its spec`);
const split = i < a.train ? 'train' : 'eval';
tasks[split].push({ id, prompt: f.prompts[i % f.prompts.length], fixture: `fixtures/${id}`, check: [process.execPath, '--test', ...specs] });
});
}
writeFileSync(join(root, 'tasks.json'), JSON.stringify(tasks, null, 2) + '\n');
console.log(`wrote ${tasks.train.length} train + ${tasks.eval.length} eval tasks to ${root}/tasks.json (all validated)`);
}
main();