mirror of
https://github.com/juxt/allium.git
synced 2026-09-14 13:56:44 +08:00
32a03809c0
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>
178 lines
5.7 KiB
JavaScript
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;
|
|
}
|