Files
PIG-Demo/scripts/conformance.mjs
T
karti-ai 2dfa96939e
ci / web (push) Successful in 2m43s
ci / python (push) Successful in 2m36s
Alert Triage: environment #2, built end to end by the pipeline
The first environment shipped through .claude/workflows/new-environment.js:
specification, three adversarial reviews (all 'fixable', none fatal), the
Python environment, the TypeScript port, captured rollouts, and the demo page.
Eleven agents, no errors.

The proof that the platform scales is one line long. Alert Triage has a
completely different shape from Word Five — JSON actions, priced lookups, an
analyst screen instead of a grid — and the only change under
src/components/demo/ is a comment edit, because the isolation lint refused the
word "wordle" there. Zero shell code changed. 415 contract checks now pass
against two demos, up from 206 against one.

The environment is honest by construction. Every alert is synthetic, generated
from the seed, and the banner saying so sits inside the board surface. Two of
the eleven scenario templates are hidden-suspicious: generated by the same code
as their benign twin with the signal overlaid only in lookup data, so the free
screen is identically distributed and a screen-only policy STRUCTURALLY cannot
tell them apart. The probe ladder measures it: `fast` catches 0.0 of hidden
seeds. That is the counterweight made real rather than asserted.

Twelve policies, thirteen ladder assertions, a genuine three-way trade:

  fast      0.846   wins hours (0.85), misses every hidden case
  targeted  0.894   wins the shipped total
  thorough  0.820   wins evidence (1.00), spends 2.9 hours

None dominates. 92 Python tests, 35 TypeScript tests, 65 fixtures replaying at
delta 0, and conformance gated on world + scorer + protocol so the browser shows
the same alert for ?seed= that Python generated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
2026-08-28 19:48:36 -07:00

440 lines
19 KiB
JavaScript
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 node
/**
* The cross-language conformance gate.
*
* Every environment under `envs/` is the reference implementation, and the
* matching `src/demos/<slug>/engine.ts` is a port of it. The site's entire
* argument is that the board you play in the browser is the same environment
* the model was evaluated in, so the two implementations agreeing is not a
* nice-to-have — it is the claim. Each environment commits the digest(s) its
* Python computes to `envs/<pkg>/CONFORMANCE.txt`; this script recomputes them
* from the TypeScript and fails if any differ.
*
* ── wordle ──────────────────────────────────────────────────────────────────
* A hand-written vector file would only ever catch the cases somebody thought
* of, and the case nobody thinks of is always the same one: repeated letters,
* where a green must claim its letter before any yellow is assigned. So the
* gate is exhaustive. For every answer A in `words/answers.json`, in list
* order, concatenate `score(G, A)` for every guess G in that same list, in that
* same order, and stream the whole thing through SHA-256. Both the list order
* and the two nested loop orders are part of the definition.
*
* ── alert-triage ────────────────────────────────────────────────────────────
* Three digests, because three things can drift:
* world canonical JSON of the generated world for seeds 04095, with the
* reference minutes the shipped policies compute. One RNG draw out
* of order anywhere in the generator changes every seed after it.
* scorer `scoreExact` over a fixed grid of synthetic episodes, as exact
* fractions so no float formatting is hashed.
* protocol parse + engine over a committed corpus of reply strings —
* accepted actions, rejection reasons and the minute meter after
* every turn. JSON-in-prose with fences and a brace scan is where
* two runtimes disagree, and the rejection reasons quote the
* offending value with Python's `repr`, so this is the digest that
* catches a port that "mostly works".
*
* Usage:
* node scripts/conformance.mjs every environment, compared to its CONFORMANCE.txt
* node scripts/conformance.mjs --only wordle one environment
* node scripts/conformance.mjs --limit 50 wordle: first 50 answers; alert-triage: first 50 seeds
* of the world digest. No comparison.
*
* An environment whose `engine.ts` does not exist yet is noted and skipped
* (exit 0) — the TypeScript port and this gate are allowed to arrive in
* either order. An environment whose CONFORMANCE.txt does not exist yet has
* its digests printed with the command that writes the file.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { abs, cyan, die, dim, exists, green, read, red, rel } from './_lib.mjs';
/* ------------------------------------------------------------------ flags */
const argv = process.argv.slice(2);
const limitFlag = argv.indexOf('--limit');
const limit = limitFlag === -1 ? 0 : Number(argv[limitFlag + 1] ?? 0);
if (limitFlag !== -1 && (!Number.isInteger(limit) || limit <= 0)) {
die('--limit takes a positive integer, e.g. `--limit 50`.');
}
const onlyFlag = argv.indexOf('--only');
const only = onlyFlag === -1 ? null : argv[onlyFlag + 1];
/* ----------------------------------------------------------- environments */
/**
* @typedef {{
* name: string,
* engine: string,
* expected: string,
* args: string[],
* probe: () => string,
* writeHint: string,
* diagnose: string,
* describe: (result: any, seconds: string) => string[],
* }} Environment
*/
/** @type {Environment[]} */
const ENVIRONMENTS = [
{
name: 'wordle',
engine: abs('src', 'demos', 'wordle', 'engine.ts'),
expected: abs('envs', 'wordle_five', 'CONFORMANCE.txt'),
args: [abs('envs', 'wordle_five', 'words', 'answers.json')],
probe: wordleProbeSource,
writeHint: 'python -c "from wordle_five.engine import conformance_digest; print(conformance_digest())"',
diagnose:
' The usual culprit is the two-pass scoring: every green must claim its letter out of the pool\n' +
' BEFORE any yellow is assigned, or a guess like SASSY against BASIS marks an S yellow that the\n' +
' greens have already spent.',
describe: (result, seconds) => [
` scored ${result.pairs.toLocaleString('en-US')} pairs from ${result.answers.toLocaleString('en-US')} answers in ${seconds}s`,
` via ${cyan(result.via)}`,
],
},
{
name: 'alert-triage',
engine: abs('src', 'demos', 'alert-triage', 'engine.ts'),
expected: abs('envs', 'alert_triage', 'CONFORMANCE.txt'),
args: [abs('envs', 'alert_triage', 'conformance', 'replies.json')],
probe: triageProbeSource,
writeHint: 'uv run python -m alert_triage.conformance --write (from envs/alert_triage)',
diagnose:
' world: an RNG draw made out of order, or a `//` that became a `/`. Bisect by seed: dump\n' +
' canonicalJson(worldForSeed(s)) on both sides and diff the first seed that differs.\n' +
' scorer: scoreExact() — check the fraction forms and the best-alternate tie-break (first wins).\n' +
' protocol: the parse rules (fence wins; first balanced span; no rescanning) or a rejection\n' +
' reason whose repr() of the offending value differs from Python.',
describe: (result, seconds) => [
` hashed ${result.seeds.toLocaleString('en-US')} worlds, ${result.grid.toLocaleString('en-US')} grid episodes and ${result.cases} corpus cases in ${seconds}s`,
],
},
];
/* ------------------------------------------------------------------ run it */
if (!exists(abs('node_modules', '.bin', 'tsx'))) die('tsx is not installed, and the engines are TypeScript. Run `pnpm install`.');
const selected = ENVIRONMENTS.filter((env) => only === null || env.name === only);
if (only !== null && selected.length === 0) {
die(`--only ${only}: no such environment. Known: ${ENVIRONMENTS.map((e) => e.name).join(', ')}.`);
}
let failed = false;
for (const env of selected) {
if (!runEnvironment(env)) failed = true;
console.log('');
}
process.exit(failed ? 1 : 0);
/** @param {Environment} env */
function runEnvironment(env) {
if (!exists(env.engine)) {
console.log(dim(`conformance[${env.name}]: engine not present yet — no ${rel(env.engine)} to check.`));
console.log(dim('The TypeScript port and this gate may land in either order; re-run once it exists.'));
return true;
}
for (const arg of env.args) {
if (!exists(arg)) die(`${rel(arg)} does not exist. It is part of the definition of the ${env.name} digest.`);
}
const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pig-conformance-'));
const probe = path.join(probeDir, 'digest.mts');
fs.writeFileSync(probe, env.probe(), 'utf8');
const started = Date.now();
console.log(dim(`conformance[${env.name}]: hashing through ${rel(env.engine)}...`));
// The tsx bin is a POSIX shell shim, not a JS file, so it is the executable
// here rather than an argument to node.
const run = spawnSync(abs('node_modules', '.bin', 'tsx'), [probe, env.engine, ...env.args, String(limit)], {
cwd: abs('.'),
encoding: 'utf8',
maxBuffer: 16 * 1024 * 1024,
timeout: 15 * 60_000,
// The probe lives in a temp directory, where tsx would never find the
// repo's tsconfig — and without it the `@/` alias in an engine does not
// resolve and the import fails for a reason that looks nothing like the cause.
env: { ...process.env, TSX_TSCONFIG_PATH: abs('tsconfig.json') },
stdio: ['ignore', 'pipe', 'inherit'],
});
fs.rmSync(probeDir, { recursive: true, force: true });
if (run.error) die(`could not run the ${env.name} digest probe: ${run.error.message}`);
if (run.status !== 0) die(`the ${env.name} digest probe exited ${run.status}. See the error above.`);
let result;
try {
result = JSON.parse(String(run.stdout).trim().split('\n').pop());
} catch {
die(`the ${env.name} digest probe produced no parseable result. Output was:\n${run.stdout}`);
}
if (result.error) {
die(`${rel(env.engine)}: ${result.error}`);
}
const seconds = ((Date.now() - started) / 1000).toFixed(1);
for (const line of env.describe(result, seconds)) console.log(line);
/** @type {Record<string, string>} */
const digests = result.digests;
const names = Object.keys(digests);
for (const name of names) console.log(` ${names.length > 1 ? name.padEnd(9) : 'digest'} ${cyan(digests[name])}`);
if (limit) {
console.log('');
console.log(dim(`Partial run (--limit ${limit}): not compared to ${rel(env.expected)}. Run without --limit for the real gate.`));
return true;
}
/* ----------------------------------------------------------- compare */
if (!exists(env.expected)) {
console.log('');
console.log(green(`conformance[${env.name}]: computed, but there is nothing to compare against yet.`));
console.log(`Write the digest(s) to ${rel(env.expected)} once the Python agrees:`);
console.log(dim(` ${env.writeHint}`));
return true;
}
const expected = parseExpected(read(env.expected), names);
if (!expected) {
die(`${rel(env.expected)} contains no 64-character hex digest${names.length > 1 ? ` for each of: ${names.join(', ')}` : ''}.`);
}
const mismatched = names.filter((name) => expected[name] !== digests[name]);
if (mismatched.length) {
console.error('');
console.error(`${red('FAIL')} the TypeScript engine and ${rel(env.expected)} disagree${names.length > 1 ? ` on: ${mismatched.join(', ')}` : ''}.`);
for (const name of mismatched) {
const label = names.length > 1 ? ` (${name})` : '';
console.error(` expected${label} ${expected[name]}`);
console.error(` got${label} ${digests[name]}`);
}
console.error('');
console.error(
' One of the two implementations is wrong, and the board on the site is therefore not the\n' +
' environment the model was evaluated in.\n' +
env.diagnose +
'\n If the Python changed on purpose, re-run its own digest and update the file:\n' +
` ${env.writeHint}`,
);
return false;
}
console.log('');
console.log(green(`conformance[${env.name}]: the TypeScript engine matches ${rel(env.expected)} exactly${names.length > 1 ? ` (${names.join(', ')})` : ''}.`));
return true;
}
/**
* Reads the committed digests. A single-digest file is one hex string on a
* line; a multi-digest file is `hex name description` per line, and every
* name the probe produced must be present.
*
* @param {string} text
* @param {string[]} names
* @returns {Record<string, string> | null}
*/
function parseExpected(text, names) {
/** @type {Record<string, string>} */
const out = {};
if (names.length === 1) {
const hex = text.match(/\b[0-9a-f]{64}\b/)?.[0];
if (!hex) return null;
out[names[0]] = hex;
return out;
}
for (const line of text.split('\n')) {
const m = line.match(/^\s*([0-9a-f]{64})\s+(\S+)/);
if (m) out[m[2]] = m[1];
}
return names.every((n) => n in out) ? out : null;
}
/* ------------------------------------------------------------------ probes */
/**
* Each probe runs under tsx so it can import the TypeScript engine directly,
* and prints one JSON line: `{ digests: { <name>: hex }, ...counts }` or
* `{ error }`.
*
* Kept here as strings rather than as checked-in `.mts` files: they are
* implementation details of this script, and a stray TypeScript file in
* `scripts/` would be swept into a typecheck it is deliberately outside of.
*
* Function declarations, not `const`s, so they are hoisted above the table
* that references them near the top of this file.
*/
function wordleProbeSource() {
return `
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
const [enginePath, answersPath, limitRaw] = process.argv.slice(2);
const limit = Number(limitRaw ?? 0);
const say = (value: unknown) => console.log(JSON.stringify(value));
let mod: any;
try {
mod = await import(enginePath);
} catch (error: any) {
say({ error: 'could not import it: ' + String(error?.message ?? error).split('\\n')[0] });
process.exit(0);
}
const answers: string[] = JSON.parse(readFileSync(answersPath, 'utf8'));
const pool = limit > 0 ? answers.slice(0, limit) : answers;
// If the engine ships the digest itself, trust it over anything reconstructed
// here — it is the implementation's own statement of the definition.
if (typeof mod.conformanceDigest === 'function' && limit === 0) {
const digest = await mod.conformanceDigest();
say({ digests: { wordle: digest }, via: 'engine.conformanceDigest()', pairs: pool.length * pool.length, answers: pool.length });
process.exit(0);
}
// Argument order is the Python's: score(guess, answer). Getting it backwards
// produces a valid-looking digest that matches nothing, so if this disagrees
// with CONFORMANCE.txt, check the signature before you check the algorithm.
const NAMES = ['scoreGuess', 'score', 'scorePattern', 'scoreWord', 'feedback', 'pattern'];
const name =
NAMES.find((n) => typeof mod[n] === 'function' && mod[n].length >= 2) ??
NAMES.find((n) => typeof mod[n] === 'function') ??
(typeof mod.default === 'function' ? 'default' : null);
if (!name) {
say({
error:
'no scoring function is exported. Found: ' + Object.keys(mod).join(', ') +
'. The gate needs a scoring function it can recognise. Export one named scoreGuess (or score), ' +
'taking (guess, answer) and returning the pattern — either a "GYXXG" string or an array of ' +
"'exact' | 'present' | 'absent' tiles. Exporting conformanceDigest() directly also works.",
});
process.exit(0);
}
const score = mod[name];
/** Every spelling of green/yellow/grey this port might reasonably have chosen. */
const TILE: Record<string, string> = {
g: 'G', y: 'Y', x: 'X',
green: 'G', yellow: 'Y', grey: 'X', gray: 'X',
exact: 'G', present: 'Y', absent: 'X',
correct: 'G', misplaced: 'Y', miss: 'X', hit: 'G',
};
function normalise(value: any): string {
if (typeof value === 'string') {
const upper = value.toUpperCase();
if (/^[GYX]+$/.test(upper)) return upper;
const chars = [...value].map((c) => TILE[c.toLowerCase()]);
if (chars.every(Boolean)) return chars.join('');
throw new Error('unrecognised pattern string ' + JSON.stringify(value));
}
if (Array.isArray(value)) {
return value
.map((tile) => {
const key = typeof tile === 'string' ? tile : tile?.state ?? tile?.tile ?? tile?.kind ?? tile?.result ?? tile?.status;
const mapped = TILE[String(key).toLowerCase()];
if (!mapped) throw new Error('unrecognised tile ' + JSON.stringify(tile));
return mapped;
})
.join('');
}
throw new Error('unrecognised pattern ' + JSON.stringify(value));
}
let convert: (value: any) => string;
try {
const sample = score(pool[0], pool[0]);
// 21 million conversions: skip the whole normaliser when the engine already
// speaks the reference alphabet, which is the case that actually ships.
convert = typeof sample === 'string' && /^[GYX]+$/.test(sample) ? (v: any) => v : normalise;
const selfScore = convert(sample);
if (!/^G+$/.test(selfScore)) {
say({ error: 'score(w, w) returned "' + selfScore + '"; a word scored against itself must be all-green' });
process.exit(0);
}
} catch (error: any) {
say({ error: String(error?.message ?? error) });
process.exit(0);
}
const hash = createHash('sha256');
const row = new Array<string>(pool.length);
for (let i = 0; i < pool.length; i += 1) {
const answer = pool[i]!;
for (let j = 0; j < pool.length; j += 1) row[j] = convert(score(pool[j]!, answer));
hash.update(row.join(''));
// Only on a TTY: written to a pipe or a CI log, a carriage return is not a
// cursor move and the progress ends up inline with the result.
if (i % 250 === 0 && process.stderr.isTTY) process.stderr.write(' ' + i + '/' + pool.length + ' answers\\r');
}
if (process.stderr.isTTY) process.stderr.write(' '.repeat(40) + '\\r');
say({ digests: { wordle: hash.digest('hex') }, via: 'engine.' + name + '(guess, answer)', pairs: pool.length * pool.length, answers: pool.length });
`;
}
/**
* The alert-triage probe. Three digests, each SHA-256 over newline-terminated
* canonical JSON lines, exactly as \`alert_triage/conformance.py\` defines
* them. The engine exports the lines; the probe only hashes them, so the
* engine stays free of node:crypto and browser-clean.
*/
function triageProbeSource() {
return `
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
const [enginePath, corpusPath, limitRaw] = process.argv.slice(2);
const limit = Number(limitRaw ?? 0);
const say = (value: unknown) => console.log(JSON.stringify(value));
let mod: any;
try {
mod = await import(enginePath);
} catch (error: any) {
say({ error: 'could not import it: ' + String(error?.message ?? error).split('\\n')[0] });
process.exit(0);
}
for (const name of ['worldLines', 'scorerLines', 'protocolLines']) {
if (typeof mod[name] !== 'function') {
say({ error: 'does not export ' + name + '(). Found: ' + Object.keys(mod).join(', ') });
process.exit(0);
}
}
function digestOf(lines: Iterable<string>, label: string, total: number): [string, number] {
const hash = createHash('sha256');
let count = 0;
for (const line of lines) {
hash.update(line, 'utf8');
hash.update('\\n');
count += 1;
if (count % 256 === 0 && process.stderr.isTTY) process.stderr.write(' ' + label + ' ' + count + '/' + total + '\\r');
}
if (process.stderr.isTTY) process.stderr.write(' '.repeat(40) + '\\r');
return [hash.digest('hex'), count];
}
const SEEDS = 4096;
const seedCount = limit > 0 ? Math.min(limit, SEEDS) : SEEDS;
const seeds = Array.from({ length: seedCount }, (_, i) => i);
const corpus = JSON.parse(readFileSync(corpusPath, 'utf8'));
try {
const [world] = digestOf(mod.worldLines(seeds), 'world', seedCount);
const [scorer, grid] = digestOf(mod.scorerLines(), 'scorer', 0);
const [protocol, cases] = digestOf(mod.protocolLines(corpus), 'protocol', corpus.length);
say({ digests: { world, scorer, protocol }, seeds: seedCount, grid, cases });
} catch (error: any) {
say({ error: String(error?.stack ?? error?.message ?? error).split('\\n').slice(0, 3).join(' | ') });
}
`;
}