/**
* Plan-quality rubric scoring for the pi-plans benchmark (I-006, D-017).
*
* DESCRIPTIVE ONLY for the treatment arm (the baseline arm produces no plan
* artifacts, so no cross-arm quality comparison exists). Four dimensions,
* 1-5 each: goal decomposition, acceptance criteria, risk anticipation,
* scope control. Judge should be a different model than the benchmark model
* (RUBRIC_JUDGE_MODEL); if the same model is unavoidable, disclose in the
* tech note. If fewer than 20 plan artifacts are available, report the actual
* count and the reason (pre-registered degrade clause, C-F010).
*
* Usage:
* node --experimental-strip-types scripts/bench/rubric.ts --results-dir
[--sample 20]
* node --experimental-strip-types scripts/bench/rubric.ts --plans-dir --offline
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { spawnSync } from "node:child_process";
const RUBRIC_DIMENSIONS = [
"goal decomposition",
"acceptance criteria",
"risk anticipation",
"scope control",
] as const;
function parseArgs(): Record {
const out: Record = {};
for (let i = 2; i < process.argv.length; i++) {
const t = process.argv[i];
if (t.startsWith("--")) {
const next = process.argv[i + 1];
out[t.replace(/^--/, "")] = next !== undefined && !next.startsWith("--") ? next : true;
if (out[t.replace(/^--/, "")] !== true) i++;
}
}
return out;
}
function findPlanFiles(root: string): string[] {
const found: string[] = [];
const walk = (dir: string): void => {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "vendor" || entry.name === "node_modules") continue;
walk(p);
} else if (entry.name === "PLAN_v1.md" || entry.name.startsWith("PLAN_v")) found.push(p);
}
};
walk(root);
return found;
}
function judgeModel(): string {
return String(process.env.RUBRIC_JUDGE_MODEL ?? "");
}
function scoreWithJudge(planText: string): Record | null {
const model = judgeModel();
if (!model) return null;
const prompt = [
"You are scoring a coding-agent PLAN artifact for research. Score each dimension 1-5 (integers).",
`Dimensions: ${RUBRIC_DIMENSIONS.join(", ")}.`,
"Respond with ONLY a JSON object like {\"goal decomposition\": 3, \"acceptance criteria\": 4, \"risk anticipation\": 2, \"scope control\": 4}.",
"--- PLAN START ---",
planText.slice(0, 20000),
"--- PLAN END ---",
].join("\n");
const res = spawnSync("pi", ["--print", "--no-session", "--model", model, prompt], {
encoding: "utf8",
timeout: 120_000,
});
const match = res.stdout?.match(/\{[^{}]*\}/s);
if (!match) return null;
try {
const parsed = JSON.parse(match[0]);
const scores: Record = {};
for (const dim of RUBRIC_DIMENSIONS) scores[dim] = Number(parsed[dim]) || 0;
return scores;
} catch {
return null;
}
}
function main(): void {
const args = parseArgs();
const resultsDir = String(args["results-dir"] ?? path.join(import.meta.dirname ?? ".", "results"));
const sample = Number(args["sample"] ?? 20);
const plans = args["plans-dir"]
? findPlanFiles(String(args["plans-dir"]))
: findPlanFiles(resultsDir).filter((p) => p.includes("treatment") || p.includes("plans"));
if (plans.length === 0) {
console.error("No PLAN_v*.md artifacts found. Run the treatment arm with plan-artifact syncing enabled.");
process.exit(1);
}
const picked = plans.slice(0, sample);
if (plans.length < sample) {
console.log(`[rubric] NOTE: only ${plans.length} plan artifacts available (<${sample}); reporting actual count (pre-registered degrade clause).`);
}
const rows: string[] = ["plan,goal_decomposition,acceptance_criteria,risk_anticipation,scope_control,average"];
const summary: Record = {};
for (const file of picked) {
const text = fs.readFileSync(file, "utf8");
let scores = args["offline"] ? null : scoreWithJudge(text);
scores ??= Object.fromEntries(RUBRIC_DIMENSIONS.map((d) => [d, 0]));
const avg = RUBRIC_DIMENSIONS.reduce((acc, d) => acc + (scores?.[d] ?? 0), 0) / RUBRIC_DIMENSIONS.length;
rows.push([path.basename(file), ...RUBRIC_DIMENSIONS.map((d) => scores?.[d] ?? 0), avg.toFixed(2)].join(","));
for (const d of RUBRIC_DIMENSIONS) {
summary[d] ??= { sum: 0, n: 0 };
summary[d].sum += scores?.[d] ?? 0;
summary[d].n += scores?.[d] ? 1 : 0;
}
}
const outDir = path.dirname(picked[0]);
const means = RUBRIC_DIMENSIONS.map((d) => `- ${d}: ${(summary[d].sum / Math.max(1, summary[d].n)).toFixed(2)} (n=${summary[d].n})`);
const md = [
"# Plan-quality rubric — treatment arm, DESCRIPTIVE ONLY (D-017)",
"",
`- Artifacts scored: ${picked.length} (requested ${sample})`,
`- Judge model: ${judgeModel() || "NOT SET — scores are placeholders; set RUBRIC_JUDGE_MODEL (prefer non-flash) or fill manually"}`,
"",
...means,
"",
"> Not comparable with the baseline arm (it produces no plan artifacts by design).",
].join("\n");
fs.writeFileSync(path.join(outDir, "rubric.md"), md);
fs.writeFileSync(path.join(outDir, "rubric.csv"), rows.join("\n"));
console.log(md);
}
main();