Files
juxt__allium/tests/tier3-evals.mjs
Matt Ford 32a03809c0 Add four-tier test suite for the Allium project
Brings up a complete test infrastructure spanning four tiers, each
testing a different layer of the project. Built incrementally over
many smaller commits and squashed here.

Tier 1 — language fixtures (free, fast)
  Per-construct fixtures under tests/fixtures/lang/ that round-trip
  through `allium check`. Covers diagnostic codes the CLI emits, with
  a drift mechanism that keeps the corpus in sync as 3.2.x evolves.

Tier 2 — doc-example validation (free, fast)
  Extracts code blocks from skills/ and reference docs and validates
  each against the language. Inline annotations and an out-of-line
  override file (tests/fixtures/docs/overrides.json) handle the long
  tail of "this example is illustrative, not buildable."

Tier 3 — skill behavioural evals (gated --live, ~$0.05–$0.50/scenario)
  `claude -p` invokes a skill against a fixture workspace; a second
  `claude -p` call scores the result against a rubric (LLM-as-judge).
  Supports both bare-API and OAuth auth modes. Rubrics under
  tests/fixtures/evals/rubrics/.

Tier 4 — end-to-end pipeline (gated --live, ~$1–$10/scenario)
  Multi-step distill→weed→tend→propagate scenarios against checked-in
  fixture projects. Cumulative workspace state at the end of the run
  is compared file-by-file against accepted snapshots.

Tier 4 features developed across this work:

  • Dual snapshot comparison: text + Allium model JSON. Either being
    a match passes.
  • Snapshot-failure judge: LLM classifies diffs as cosmetic /
    structural / semantic and gates pass/fail accordingly. Default
    haiku, ~$0.05 per failed snapshot. Verdict cached per kept run.
  • Kept workspaces (--keep-workspace): preserves the workspace +
    per-step checkpoints + a manifest at tests/.tier4-runs/<scenario>-<ts>/
    so expensive generation output can be inspected, snapshotted,
    and replayed without regenerating.
  • Replay mode (--workspace <path>): re-runs the snapshot comparison
    against a kept workspace at no API cost.
  • Auto-resume (--workspace <path> after scenario steps were added):
    detects checkpoint vs scenario-step count, resumes from the last
    checkpoint as the working state.
  • Variance scenarios (repeat: N): a scenario can opt into running
    N independent times into runs/01..NN/ subdirs under one parent
    kept dir, with a configurable concurrency cap (--concurrency N,
    default 3). Used to investigate distill-output stability.
  • Convergence-report script (scripts/tier4-convergence.mjs): diffs
    a tracked file across consecutive checkpoints; with --judge,
    classifies each transition. Verdicts cached.
  • Variance-report script (scripts/tier4-variance.mjs): line-count
    distribution + pairwise (N choose 2) judge verdicts + clustering
    (cosmetic + structural pairs treated as behaviourally equivalent).
    Verdicts cached. --pair drilldown for one specific pair.

Auth modes: both bare ANTHROPIC_API_KEY and OAuth (--oauth) supported
across Tier 3 and Tier 4. The --json-schema flag was found incompatible
with OAuth/agent mode and dropped; envelope JSON is parsed instead.

UX: pretty output (colors + glyphs) with --plain opt-out, an allium
CLI version banner, in-place heartbeat updates on interactive
terminals, --verbose to stream claude stderr + pass --debug.

Snapshot baselines committed for the three node-todo scenarios
(distill-only, distill-then-tend, full-pipeline) plus the two
variance-investigation scenarios (weed-bounded, weed-convergence).

Documentation: tests/README.md (running, scenario authoring, kept
workspaces, replay, variance), tests/RATIONALE.md (the why behind
the tier split), and four iteration-by-iteration variance reports
in tests/docs/ that drove the prompt changes in the follow-up
commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:33:37 +01:00

305 lines
9.5 KiB
JavaScript

// Tier 3 — Skill behavioural evals
//
// For each scenario in tests/fixtures/evals/scenarios/*.json:
// 1. mkdtemp a workspace
// 2. write the setup files into it
// 3. run `claude -p '<prompt>'` against the workspace, with our skills
// loaded via --plugin-dir
// 4. run each assertion against the resulting workspace state
// 5. tear down
//
// Tier 3 is gated behind --live because every scenario costs API spend.
// Without --live the runner skips, never fails. Without `claude` on PATH
// or ANTHROPIC_API_KEY set, same.
//
// Scenario JSON schema (see tests/fixtures/evals/scenarios/README.md
// once authored):
//
// {
// "id": "tend-fix-syntax-error",
// "skill": "tend",
// "setup": { "files": { "<rel-path>": "<content>" } },
// "prompt": "/tend fix the syntax error in broken.allium",
// "budget_usd": 0.5,
// "model": "sonnet",
// "assertions": [
// { "type": "file-exists", "path": "broken.allium" },
// { "type": "cli-passes", "cmd": ["allium", "check", "broken.allium"] },
// { "type": "judge", "rubric": "tend.md",
// "criteria": ["minimal-edit", "preserves-intent"] }
// ]
// }
//
// Assertions are evaluated in order; failures are reported but later
// assertions still run so the report is complete.
import {
mkdtempSync,
writeFileSync,
mkdirSync,
readdirSync,
statSync,
readFileSync,
rmSync,
existsSync,
} from "fs";
import { tmpdir } from "os";
import path from "path";
import { fileURLToPath } from "url";
import { execFile } from "child_process";
import { promisify } from "util";
import {
invoke,
isAvailable as claudeAvailable,
getAuthMode,
canAuthenticate,
} from "./lib/claude.mjs";
import { judge } from "./lib/judge.mjs";
import { isAvailable as alliumAvailable } from "./lib/allium-cli.mjs";
import { createReporter, summarise } from "./lib/reporter.mjs";
import { printBanner } from "./lib/banner.mjs";
import { interactive } from "./lib/style.mjs";
const execFileAsync = promisify(execFile);
const HERE = path.dirname(fileURLToPath(import.meta.url));
const SCENARIOS_DIR = path.join(HERE, "fixtures", "evals", "scenarios");
const RUBRICS_DIR = path.join(HERE, "fixtures", "evals", "rubrics");
export async function run({ filters = [], live = false, quiet = false, verbose = false } = {}) {
const reporter = createReporter();
reporter.section("Tier 3 — skill behavioural evals");
if (!live) {
reporter.skip("tier3", "not in --live mode (Tier 3 costs API spend)");
return reporter.getCounters();
}
if (!(await claudeAvailable())) {
reporter.skip("tier3", "`claude` CLI not on PATH");
return reporter.getCounters();
}
const mode = getAuthMode();
if (!canAuthenticate(mode)) {
reporter.skip(
"tier3",
`auth mode '${mode}' has no credentials — set ANTHROPIC_API_KEY for bare, or pass --oauth to use claude's OAuth login`
);
return reporter.getCounters();
}
const scenarios = loadScenarios(SCENARIOS_DIR).filter((s) =>
matches(s, filters)
);
if (scenarios.length === 0) {
reporter.skip("tier3", "no scenarios matched filters");
return reporter.getCounters();
}
if (!quiet) console.log(` auth mode: ${mode}`);
for (const scenario of scenarios) {
await runScenario(scenario, reporter, quiet, verbose);
}
return reporter.getCounters();
}
function loadScenarios(dir) {
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter((f) => f.endsWith(".json"))
.map((f) => ({
file: path.join(dir, f),
data: JSON.parse(readFileSync(path.join(dir, f), "utf-8")),
}));
}
function matches(scenario, filters) {
if (filters.length === 0) return true;
return filters.some(
(f) => scenario.data.id === f || scenario.data.skill === f
);
}
async function runScenario(scenario, reporter, quiet, verbose) {
const { id, skill, setup, prompt, assertions, budget_usd, model } =
scenario.data;
const workspace = mkdtempSync(path.join(tmpdir(), `allium-eval-${id}-`));
let beforeSnapshot;
let afterSnapshot;
try {
writeSetup(workspace, setup);
beforeSnapshot = snapshot(workspace);
if (!quiet) console.log(` running: ${id}`);
const inPlaceTick = interactive && !verbose;
const tickInterval = inPlaceTick ? 1000 : 15000;
const invokeStartedAt = Date.now();
const result = await invoke({
prompt,
cwd: workspace,
budgetUsd: budget_usd,
model,
onTick: quiet ? undefined : heartbeat(` ${id}`, inPlaceTick),
tickIntervalMs: tickInterval,
onStderr: verbose ? (chunk) => process.stderr.write(chunk) : undefined,
debug: verbose,
});
const invokeElapsed = Date.now() - invokeStartedAt;
if (inPlaceTick && !quiet) clearHeartbeatLine();
afterSnapshot = snapshot(workspace);
if (!result.ok) {
reporter.fail(`${id}/invoke`, `claude exited ${result.code}`, invokeElapsed);
return;
}
if (!quiet) reporter.pass(`${id}/invoke`, invokeElapsed);
for (const assertion of assertions) {
await runAssertion(
assertion,
{ id, skill, workspace, prompt, beforeSnapshot, afterSnapshot, transcript: result.stdout },
reporter,
quiet
);
}
} finally {
rmSync(workspace, { recursive: true, force: true });
}
}
function writeSetup(workspace, setup) {
if (!setup?.files) return;
for (const [relPath, content] of Object.entries(setup.files)) {
const full = path.join(workspace, relPath);
mkdirSync(path.dirname(full), { recursive: true });
writeFileSync(full, content);
}
}
function snapshot(dir) {
const out = {};
walk(dir, (relPath) => {
out[relPath] = readFileSync(path.join(dir, relPath), "utf-8");
});
return out;
}
function walk(dir, fn, prefix = "") {
for (const entry of readdirSync(dir)) {
const full = path.join(dir, entry);
const s = statSync(full);
if (s.isDirectory()) walk(full, fn, path.join(prefix, entry));
else fn(path.join(prefix, entry));
}
}
async function runAssertion(assertion, ctx, reporter, quiet) {
const tag = `${ctx.id}/${assertion.type}`;
const startedAt = Date.now();
const elapsed = () => Date.now() - startedAt;
switch (assertion.type) {
case "file-exists":
if (existsSync(path.join(ctx.workspace, assertion.path))) {
if (!quiet) reporter.pass(tag, elapsed());
} else {
reporter.fail(tag, `expected file ${assertion.path} to exist`, elapsed());
}
break;
case "cli-passes":
try {
await execFileAsync(assertion.cmd[0], assertion.cmd.slice(1), {
cwd: ctx.workspace,
});
if (!quiet) reporter.pass(tag, elapsed());
} catch (e) {
reporter.fail(
tag,
`${assertion.cmd.join(" ")} exited ${e.code ?? "non-zero"}`,
elapsed()
);
}
break;
case "judge": {
if (!(await alliumAvailable())) {
reporter.skip(tag, "judge skipped: `allium` not on PATH", elapsed());
return;
}
const rubricPath = path.join(RUBRICS_DIR, assertion.rubric);
if (!existsSync(rubricPath)) {
reporter.fail(tag, `rubric not found: ${rubricPath}`, elapsed());
return;
}
const verdict = await judge({
rubricPath,
variables: {
before: JSON.stringify(ctx.beforeSnapshot, null, 2),
after: JSON.stringify(ctx.afterSnapshot, null, 2),
prompt: ctx.prompt,
criteria: (assertion.criteria ?? []).join(", "),
},
});
const judgeElapsed = elapsed();
if (!verdict.ok) {
reporter.fail(tag, `judge failed: ${verdict.error}`, judgeElapsed);
return;
}
// The judge call's wall clock is reported once on the parent
// tag; per-criterion lines are scoring breakdowns within that
// single call and don't have meaningful sub-times.
if (!quiet) reporter.pass(tag, judgeElapsed);
for (const v of verdict.verdicts) {
const subTag = `${tag}:${v.criterion}`;
if (v.verdict === "pass") {
if (!quiet) reporter.pass(`${subTag} — ${v.reason}`);
} else {
reporter.fail(subTag, `${v.verdict}: ${v.reason}`);
}
}
break;
}
default:
reporter.fail(tag, `unknown assertion type: ${assertion.type}`, elapsed());
}
}
// Heartbeat callback for runClaude. Returns a function that prints
// "<prefix> ⏱ Ns elapsed" each time it's invoked, so the user can see
// the long-running step is still alive. When `inPlace` is true, the
// caller has decided in-place updating is safe (interactive TTY,
// no concurrent stderr stream) — write \r-prefixed and clear the
// rest of the line so each tick overwrites the last.
function heartbeat(prefix, inPlace) {
return (elapsedMs) => {
const seconds = Math.round(elapsedMs / 1000);
if (inPlace) {
process.stdout.write(`\r${prefix} ⏱ ${seconds}s elapsed\x1b[K`);
} else {
console.log(`${prefix} ⏱ ${seconds}s elapsed`);
}
};
}
// Erase the in-place heartbeat line so the next reporter line lands
// cleanly.
function clearHeartbeatLine() {
process.stdout.write("\r\x1b[K");
}
if (import.meta.url === `file://${process.argv[1]}`) {
printBanner();
const filters = process.argv.slice(2).filter((a) => !a.startsWith("--"));
const live = process.argv.includes("--live");
const quiet = process.argv.includes("--quiet");
const verbose = process.argv.includes("--verbose");
const counters = await run({ filters, live, quiet, verbose });
summarise("Tier 3", counters);
process.exit(counters.failed === 0 ? 0 : 1);
}