Files
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

178 lines
5.7 KiB
JavaScript

// LLM-as-judge harness.
//
// Given a rubric template (markdown with {{placeholders}}), a workspace
// before/after snapshot, and the agent's transcript, ask Claude to
// score the run against the rubric criteria.
//
// IMPORTANT: this implementation deliberately does NOT use claude's
// `--json-schema` flag. Empirically (probed against claude 2.1.126,
// see commit log) `--json-schema` is incompatible with OAuth/agent
// mode: with --json-schema set and no --bare, the model returns
// "Done." (treating the prompt as agentic work) instead of the
// schema-conforming JSON. Disabling all tools via --disallowedTools
// does not help — the schema enforcement path itself is broken in
// agent mode.
//
// Workaround:
//
// 1. Use --output-format json so claude returns a stable envelope
// around the model's text response (always exits cleanly with a
// `result` field that holds the model output verbatim).
// 2. Ask for the JSON shape explicitly in the rubric prompt.
// 3. Strip markdown code fences from the response (haiku in
// particular tends to wrap JSON in ```json ... ``` despite the
// "no fences" instruction).
// 4. Parse the cleaned text. The expected shape is a JSON array of
// verdict objects matching the rubric's instructions.
//
// Output the runner sees:
// { ok: true, verdicts: [ {criterion, verdict, reason}, ... ] }
// { ok: false, error: <short reason>, raw: <first 1KB of model output> }
//
// Default model: haiku unless overridden. Judge is a low-stakes
// scoring call; haiku is plenty and ~30x cheaper than opus.
import { readFileSync } from "fs";
import { getAuthMode, runClaude } from "./claude.mjs";
const DEFAULT_JUDGE_MODEL = "claude-haiku-4-5";
export async function judge({
rubricPath,
variables,
budgetUsd,
model = DEFAULT_JUDGE_MODEL,
timeoutMs,
authMode = getAuthMode(),
}) {
const rubric = render(readFileSync(rubricPath, "utf-8"), variables);
// OAuth mode loads the user's CLAUDE.md, hooks, plugin sync and
// auto-memory — adds startup tokens AND turns the call into an
// agent run rather than a one-shot text response. Bigger budget and
// longer timeout absorb the overhead.
const effectiveBudget =
budgetUsd ?? (authMode === "oauth" ? 0.5 : 0.1);
const effectiveTimeout =
timeoutMs ?? (authMode === "oauth" ? 3 * 60 * 1000 : 90 * 1000);
const args = ["-p"];
if (authMode === "bare") args.push("--bare");
args.push(
"--max-budget-usd",
String(effectiveBudget),
"--model",
model,
"--output-format",
"json"
);
args.push(rubric);
const result = await runClaude(args, {
timeoutMs: effectiveTimeout,
maxBuffer: 5 * 1024 * 1024,
});
if (!result.ok) {
return {
ok: false,
error: explainFailure(result, effectiveBudget, effectiveTimeout),
raw: result.stdout,
};
}
// claude --output-format json wraps the response in:
// {"type":"result","subtype":"success","result":"<model text>", ...}
let envelope;
try {
envelope = JSON.parse(result.stdout);
} catch (e) {
return {
ok: false,
error: `claude envelope was not valid JSON: ${String(e).slice(0, 80)} | stdout=${preview(result.stdout)}`,
raw: result.stdout.slice(0, 1000),
};
}
if (envelope.is_error || envelope.subtype !== "success") {
const subtype = envelope.subtype || "unknown";
const errs = Array.isArray(envelope.errors)
? envelope.errors.join("; ")
: "";
return {
ok: false,
error: `claude returned ${subtype}${errs ? `: ${errs}` : ""}`,
raw: result.stdout.slice(0, 1000),
};
}
const modelText = (envelope.result ?? "").trim();
if (!modelText) {
return {
ok: false,
error: `model returned empty result (cost=$${envelope.total_cost_usd ?? "?"}, turns=${envelope.num_turns ?? "?"}, stop=${envelope.stop_reason ?? "?"}) — likely agent-mode pollution; consider setting ANTHROPIC_API_KEY and removing --oauth`,
raw: result.stdout.slice(0, 1000),
};
}
const cleaned = stripCodeFences(modelText);
let verdicts;
try {
verdicts = JSON.parse(cleaned);
} catch (e) {
return {
ok: false,
error: `model output was not valid JSON: ${String(e).slice(0, 80)} | response=${preview(cleaned)}`,
raw: modelText.slice(0, 1000),
};
}
if (!Array.isArray(verdicts)) {
return {
ok: false,
error: `model returned non-array: ${preview(cleaned)}`,
raw: modelText.slice(0, 1000),
};
}
return { ok: true, verdicts };
}
function render(template, vars) {
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => {
if (!(key in vars)) {
throw new Error(`Rubric variable {{${key}}} not provided`);
}
return String(vars[key]);
});
}
// Strip ```json ... ``` or ``` ... ``` wrappers if the model added
// them despite the rubric's "no fences" instruction. Haiku in
// particular tends to do this.
function stripCodeFences(text) {
const m = text.match(/^\s*```(?:json|JSON)?\s*\n?([\s\S]*?)\n?\s*```\s*$/);
return m ? m[1].trim() : text.trim();
}
function explainFailure(result, budget, timeoutMs) {
if (result.timedOut) return `timed out after ${timeoutMs / 1000}s`;
if (
/budget|exceeded/i.test(result.stderr) ||
/budget|exceeded/i.test(result.stdout)
) {
return `budget $${budget} exceeded`;
}
const stderrSummary = preview(result.stderr);
const stdoutSummary = preview(result.stdout);
return `exit ${result.code} | stderr=${stderrSummary} | stdout=${stdoutSummary}`;
}
function preview(s, max = 240) {
if (s == null || s.length === 0) return "<empty>";
const oneLine = s.split("\n").filter(Boolean).join(" | ");
if (!oneLine) return "<whitespace only>";
return oneLine.length > max ? oneLine.slice(0, max) + "…" : oneLine;
}