pgroast 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +173 -2
- package/bin/pgroast.mjs +19 -0
- package/data/precedents.sql.gz +0 -0
- package/docs/corpus.md +352 -0
- package/docs/design.md +540 -0
- package/docs/eval.md +245 -0
- package/docs/haas-paranoia-notes.md +223 -0
- package/invariants/README.md +15 -0
- package/invariants/nbtree.md +14 -0
- package/package.json +51 -4
- package/prompts/brief.md +32 -0
- package/prompts/committer-paranoia.md +149 -0
- package/prompts/critic.md +56 -0
- package/prompts/enrich.md +35 -0
- package/prompts/pushback-map.md +58 -0
- package/prompts/pushback-reduce.md +72 -0
- package/prompts/reflect.md +20 -0
- package/prompts/relevance.md +20 -0
- package/prompts/roaster-already-exists.md +23 -0
- package/prompts/roaster-common.md +88 -0
- package/prompts/roaster-demand.md +28 -0
- package/prompts/roaster-maintenance-fit.md +22 -0
- package/prompts/roaster-prior-attempts.md +29 -0
- package/prompts/roaster-replication-ondisk.md +24 -0
- package/prompts/roaster-security.md +21 -0
- package/prompts/roaster-standards-compat.md +21 -0
- package/prompts/supervisor.md +31 -0
- package/prompts/synth-self.md +19 -0
- package/prompts/synth.md +140 -0
- package/prompts/tone.md +21 -0
- package/prompts/writing.md +98 -0
- package/sql/schema.sql +45 -0
- package/src/cli.ts +391 -0
- package/src/corpus/cf.ts +220 -0
- package/src/corpus/cli.ts +189 -0
- package/src/corpus/db.ts +119 -0
- package/src/corpus/fetch.ts +104 -0
- package/src/corpus/reverts.ts +133 -0
- package/src/corpus/search.ts +201 -0
- package/src/corpus/util.ts +133 -0
- package/src/corpus/wiki.ts +80 -0
- package/src/eval/cases.ts +118 -0
- package/src/eval/cli.ts +105 -0
- package/src/eval/judge.ts +136 -0
- package/src/eval/report.ts +268 -0
- package/src/eval/run.ts +153 -0
- package/src/eval/score.ts +275 -0
- package/src/roast/doctor.ts +109 -0
- package/src/roast/enrich.ts +335 -0
- package/src/roast/invariants.ts +65 -0
- package/src/roast/llm.ts +601 -0
- package/src/roast/material.ts +133 -0
- package/src/roast/page.ts +266 -0
- package/src/roast/pipeline.ts +981 -0
- package/src/roast/precedents.ts +308 -0
- package/src/roast/pushback.ts +664 -0
- package/src/roast/render.ts +386 -0
- package/src/roast/sanitize.ts +140 -0
- package/src/roast/schema.ts +714 -0
- package/src/roast/tone.ts +216 -0
- package/src/thread/fetch.ts +172 -0
- package/src/thread/load.ts +227 -0
- package/src/thread/local.ts +73 -0
- package/src/thread/parse.ts +243 -0
- package/src/thread/patchprose.ts +143 -0
- package/src/thread/types.ts +120 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// (b) judge: one model call per case, through the roast LLM backend (critic
|
|
2
|
+
// model, no tools). It compares the case's decisive objections with the
|
|
3
|
+
// objections the roast produced. Buckets are NOT shown to the judge (so a
|
|
4
|
+
// "refuted" label cannot bias it); code looks the bucket up afterwards.
|
|
5
|
+
|
|
6
|
+
import type { JsonSchema, Roast } from "../roast/schema.ts";
|
|
7
|
+
import type { LlmBackend } from "../roast/llm.ts";
|
|
8
|
+
import type { EvalCase } from "./cases.ts";
|
|
9
|
+
import { bucketedObjections, type JudgeItem } from "./score.ts";
|
|
10
|
+
|
|
11
|
+
export const JUDGE_SCHEMA: JsonSchema = {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
items: {
|
|
15
|
+
type: "array",
|
|
16
|
+
minItems: 1,
|
|
17
|
+
items: {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
decisive_index: { type: "integer", minimum: 1 },
|
|
21
|
+
result: { type: "string", enum: ["found", "partially", "not_found"] },
|
|
22
|
+
objection_id: { type: "string", nullable: true },
|
|
23
|
+
reason: { type: "string" },
|
|
24
|
+
},
|
|
25
|
+
required: ["decisive_index", "result", "objection_id", "reason"],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
required: ["items"],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const JUDGE_SYSTEM = `You grade an automated reviewer of PostgreSQL proposals.
|
|
33
|
+
|
|
34
|
+
You get (1) the objection(s) that actually decided a historical proposal (who raised it, when, a quote), and (2) the list of objections the reviewer produced without seeing that discussion.
|
|
35
|
+
|
|
36
|
+
For EACH decisive objection, answer:
|
|
37
|
+
- "found": one reviewer objection states the same core reason: the same mechanism, risk or concern, even in different words or with a different example.
|
|
38
|
+
- "partially": a reviewer objection is in the same area but weaker or more generic (e.g. "maintenance burden" where the real reason was a specific maintenance problem), or covers only part of the real reason.
|
|
39
|
+
- "not_found": no reviewer objection matches.
|
|
40
|
+
|
|
41
|
+
Give the id of the single best-matching reviewer objection (null for not_found) and one short sentence explaining the match or the miss. Judge only by the text given; do not use what you may remember about the real thread. Be strict: topical overlap is not a match. Reply with JSON only.`;
|
|
42
|
+
|
|
43
|
+
const clip = (s: string, n: number) => (s.length > n ? s.slice(0, n) + "…" : s);
|
|
44
|
+
|
|
45
|
+
/** The decisive objections as shown to the classifier and the judge. */
|
|
46
|
+
export function decisiveList(c: EvalCase): string {
|
|
47
|
+
return c.decisive_objections.map((d, i) => {
|
|
48
|
+
const src = d.message_url ? "" : d.source ? `\n (source: ${clip(String(d.source), 200)})` : "";
|
|
49
|
+
return `D${i + 1}. ${d.who ?? "unknown"}${d.date ? `, ${d.date}` : ""}${d.category ? ` [${d.category}]` : ""}\n "${clip(String(d.quote ?? ""), 1500)}"${src}`;
|
|
50
|
+
}).join("\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function judgePrompt(c: EvalCase, r: Roast): string {
|
|
54
|
+
const dec = decisiveList(c);
|
|
55
|
+
const objs = bucketedObjections(r).map((o) =>
|
|
56
|
+
`- id: ${o.id}\n title: ${o.title}\n severity: ${o.severity}; category: ${o.category}; level: ${o.level}\n failure scenario: ${clip(o.failure_scenario, 900)}\n argument: ${clip(o.argument, 900)}`).join("\n");
|
|
57
|
+
return `Proposal: ${c.title}
|
|
58
|
+
Outcome: ${c.outcome}
|
|
59
|
+
|
|
60
|
+
Decisive objection(s) from the real discussion:
|
|
61
|
+
${dec}
|
|
62
|
+
|
|
63
|
+
Reviewer objections (${bucketedObjections(r).length}):
|
|
64
|
+
${objs || "(none)"}
|
|
65
|
+
|
|
66
|
+
Return {"items": [...]} with one item per decisive objection; decisive_index is the number after D.`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function runJudge(c: EvalCase, r: Roast, backend: LlmBackend, model: string): Promise<JudgeItem[]> {
|
|
70
|
+
const out = await backend.complete<{ items: JudgeItem[] }>({
|
|
71
|
+
system: JUDGE_SYSTEM, prompt: judgePrompt(c, r), model, schema: JUDGE_SCHEMA, label: "judge",
|
|
72
|
+
});
|
|
73
|
+
return out.items.filter((i) => i.decisive_index >= 1 && i.decisive_index <= c.decisive_objections.length);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------- level classification
|
|
77
|
+
// A separate call that sees only the case (never the roast), so the label is
|
|
78
|
+
// independent of what the roast said. Levels from prompts/committer-paranoia.md.
|
|
79
|
+
|
|
80
|
+
export type DecisiveLevel = "idea" | "design" | "implementation";
|
|
81
|
+
|
|
82
|
+
export interface ClassifyItem {
|
|
83
|
+
decisive_index: number;
|
|
84
|
+
level: DecisiveLevel;
|
|
85
|
+
reason: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const CLASSIFY_SCHEMA: JsonSchema = {
|
|
89
|
+
type: "object",
|
|
90
|
+
properties: {
|
|
91
|
+
items: {
|
|
92
|
+
type: "array",
|
|
93
|
+
minItems: 1,
|
|
94
|
+
items: {
|
|
95
|
+
type: "object",
|
|
96
|
+
properties: {
|
|
97
|
+
decisive_index: { type: "integer", minimum: 1 },
|
|
98
|
+
level: { type: "string", enum: ["idea", "design", "implementation"] },
|
|
99
|
+
reason: { type: "string" },
|
|
100
|
+
},
|
|
101
|
+
required: ["decisive_index", "level", "reason"],
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
required: ["items"],
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const CLASSIFY_SYSTEM = `You classify objections raised against PostgreSQL proposals by the level of the review pyramid they belong to (Robert Haas's patch review hierarchy, as used by the PG Roast rubric):
|
|
109
|
+
|
|
110
|
+
- "idea" (levels 1a and 1b): understandability and desirability. Is the problem clear, is it worth solving at all, does it belong in core, is there demand, is it a non-standard or unwanted feature, was it rejected before as an idea.
|
|
111
|
+
- "design" (level 2): architecture and design in broad strokes. Given the goal, is the approach right: interfaces, user-visible behaviour, compatibility, on-disk/WAL/protocol/catalog consequences, security model, interaction with other subsystems, maintenance cost of the chosen approach.
|
|
112
|
+
- "implementation" (levels 3-5): code structure, specific bugs, crashes, test or buildfarm failures, performance regressions of the particular code, missing docs/tests, style. An objection that needs the code (or running it) to be seen.
|
|
113
|
+
|
|
114
|
+
Classify by what the objection is about, not by who raised it or how the proposal ended. Reply with JSON only.`;
|
|
115
|
+
|
|
116
|
+
export function classifyPrompt(c: EvalCase): string {
|
|
117
|
+
return `Proposal: ${c.title}
|
|
118
|
+
|
|
119
|
+
Decisive objection(s):
|
|
120
|
+
${decisiveList(c)}
|
|
121
|
+
|
|
122
|
+
Return {"items": [...]} with one item per decisive objection (decisive_index is the number after D): level and one short sentence of reason.`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function runClassify(c: EvalCase, backend: LlmBackend, model: string): Promise<ClassifyItem[]> {
|
|
126
|
+
const out = await backend.complete<{ items: ClassifyItem[] }>({
|
|
127
|
+
system: CLASSIFY_SYSTEM, prompt: classifyPrompt(c), model, schema: CLASSIFY_SCHEMA, label: "classify",
|
|
128
|
+
});
|
|
129
|
+
return out.items.filter((i) => i.decisive_index >= 1 && i.decisive_index <= c.decisive_objections.length);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Case label: the most fundamental level among its decisive objections. */
|
|
133
|
+
export function caseLevel(items: ClassifyItem[]): DecisiveLevel | null {
|
|
134
|
+
for (const l of ["idea", "design", "implementation"] as const) if (items.some((i) => i.level === l)) return l;
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Score a run and write eval/results/<run-id>/report.{md,json}.
|
|
2
|
+
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { connect } from "../corpus/db.ts";
|
|
6
|
+
import { flatUrl } from "../corpus/util.ts";
|
|
7
|
+
import { CliBackend, DryRunBackend, modelsFromEnv, pickBackend, type BackendName, type LlmBackend } from "../roast/llm.ts";
|
|
8
|
+
import type { Roast } from "../roast/schema.ts";
|
|
9
|
+
import { msgidOf, type EvalCase } from "./cases.ts";
|
|
10
|
+
import { runClassify, runJudge, type ClassifyItem } from "./judge.ts";
|
|
11
|
+
import { RESULTS_DIR, type CaseMeta } from "./run.ts";
|
|
12
|
+
import { headline, scoreCase, type CaseScore, type CorpusThreadRow, type Headline, type JudgeItem } from "./score.ts";
|
|
13
|
+
|
|
14
|
+
const readJson = <T>(p: string): T | null => (existsSync(p) ? (JSON.parse(readFileSync(p, "utf8")) as T) : null);
|
|
15
|
+
|
|
16
|
+
/** Corpus rows holding any of the given thread message-ids. */
|
|
17
|
+
export async function corpusRowsFor(msgids: string[]): Promise<CorpusThreadRow[]> {
|
|
18
|
+
if (!msgids.length) return [];
|
|
19
|
+
const sql = connect();
|
|
20
|
+
try {
|
|
21
|
+
const urls = JSON.stringify([...new Set(msgids)].map(flatUrl));
|
|
22
|
+
const rows = await sql`
|
|
23
|
+
select id, to_char(date, 'YYYY-MM-DD') as date, to_char(first_date, 'YYYY-MM-DD') as first_date,
|
|
24
|
+
array_to_json(thread_urls)::text as thread_urls
|
|
25
|
+
from precedent
|
|
26
|
+
where thread_urls && array(select jsonb_array_elements_text((${urls}::text)::jsonb))`;
|
|
27
|
+
return rows.map((r: { id: string; date: string | null; first_date: string | null; thread_urls: string }) =>
|
|
28
|
+
({ id: r.id, date: r.date, first_date: r.first_date, thread_urls: JSON.parse(r.thread_urls) as string[] }));
|
|
29
|
+
} finally {
|
|
30
|
+
await sql.close();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ScoreOptions {
|
|
35
|
+
runId: string;
|
|
36
|
+
cases: EvalCase[];
|
|
37
|
+
judge?: boolean;
|
|
38
|
+
backend?: BackendName;
|
|
39
|
+
log?: (m: string) => void;
|
|
40
|
+
/** injected for tests */
|
|
41
|
+
judgeBackend?: (dir: string, dryRun: boolean) => LlmBackend;
|
|
42
|
+
corpusRows?: CorpusThreadRow[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Report {
|
|
46
|
+
run_id: string;
|
|
47
|
+
scored_at: string;
|
|
48
|
+
/** true when any roast in the run came from the DryRunBackend */
|
|
49
|
+
dry_run: boolean;
|
|
50
|
+
rules: string;
|
|
51
|
+
cases_in_file: number;
|
|
52
|
+
cases_scored: number;
|
|
53
|
+
repeats: number;
|
|
54
|
+
headline: Headline;
|
|
55
|
+
by_post_training_cutoff: { true: Headline; false: Headline; unknown: Headline };
|
|
56
|
+
by_outcome: Record<string, Headline>;
|
|
57
|
+
/** by the most fundamental level of the decisive objection (idea / design / implementation / unclassified) */
|
|
58
|
+
by_decisive_level: Record<string, Headline>;
|
|
59
|
+
by_repeat: Headline[] | null;
|
|
60
|
+
agreement: { case_id: string; verdicts: (string | null)[]; modal_share: number }[] | null;
|
|
61
|
+
scores: CaseScore[];
|
|
62
|
+
totals: { roast_calls: number; judge_calls: number; cost_usd: number | null; wall_ms: number };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function repeatDirs(runId: string, caseId: string): { k: number; dir: string }[] {
|
|
66
|
+
const base = join(RESULTS_DIR, runId, caseId);
|
|
67
|
+
if (!existsSync(base)) return [];
|
|
68
|
+
const rs = readdirSync(base).filter((d) => /^r\d+$/.test(d)).map((d) => ({ k: Number(d.slice(1)), dir: join(base, d) }));
|
|
69
|
+
return rs.length ? rs.sort((a, b) => a.k - b.k) : [{ k: 1, dir: base }];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function scoreRun(o: ScoreOptions): Promise<Report> {
|
|
73
|
+
const log = o.log ?? ((m: string) => console.error(m));
|
|
74
|
+
const runDir = join(RESULTS_DIR, o.runId);
|
|
75
|
+
if (!existsSync(runDir)) throw new Error(`no such run: ${runDir}`);
|
|
76
|
+
const wanted = o.cases.flatMap((c) => c.earlier_attempts.map((e) => msgidOf(e.thread_url)).filter((x): x is string => !!x));
|
|
77
|
+
const corpusRows = o.corpusRows ?? (await corpusRowsFor(wanted));
|
|
78
|
+
const scores: CaseScore[] = [];
|
|
79
|
+
let repeats = 1;
|
|
80
|
+
let dryRun = false;
|
|
81
|
+
for (const c of o.cases) {
|
|
82
|
+
const dirs = repeatDirs(o.runId, c.id);
|
|
83
|
+
if (!dirs.length) continue; // case not part of this run
|
|
84
|
+
repeats = Math.max(repeats, dirs.length);
|
|
85
|
+
for (const { k, dir } of dirs) {
|
|
86
|
+
const r = readJson<Roast>(join(dir, "roast.json"));
|
|
87
|
+
if (r?.backend === "dry-run") dryRun = true;
|
|
88
|
+
const meta = readJson<CaseMeta>(join(dir, "case-meta.json"));
|
|
89
|
+
const err = readJson<{ error: string }>(join(dir, "error.json"));
|
|
90
|
+
let judge: JudgeItem[] | null = null;
|
|
91
|
+
let judgeCalls = 0;
|
|
92
|
+
let judgeCost: number | null = null;
|
|
93
|
+
let classify: ClassifyItem[] | null = null;
|
|
94
|
+
if (r && c.decisive_objections.length) {
|
|
95
|
+
const dry = r.backend === "dry-run";
|
|
96
|
+
const mk = () => o.judgeBackend?.(dir, dry) ?? (dry
|
|
97
|
+
? new DryRunBackend({ transcriptDir: join(dir, "judge-llm"), print: () => undefined })
|
|
98
|
+
: new CliBackend({ backend: pickBackend(o.backend), transcriptDir: join(dir, "judge-llm"), log }));
|
|
99
|
+
const model = modelsFromEnv(process.env, dry ? "claude" : pickBackend(o.backend)).critic;
|
|
100
|
+
// 1. level classification: sees the case only, never the roast; runs first
|
|
101
|
+
const cc = readJson<{ items: ClassifyItem[]; calls: number; cost_usd: number | null }>(join(dir, "classify.json"));
|
|
102
|
+
if (cc) {
|
|
103
|
+
classify = cc.items;
|
|
104
|
+
judgeCalls += cc.calls;
|
|
105
|
+
judgeCost = cc.cost_usd;
|
|
106
|
+
} else if (o.judge !== false) {
|
|
107
|
+
const b = mk();
|
|
108
|
+
try {
|
|
109
|
+
classify = await runClassify(c, b, model);
|
|
110
|
+
writeFileSync(join(dir, "classify.json"), JSON.stringify({ items: classify, calls: b.stats().calls, cost_usd: b.stats().costUsd, model, dry_run: dry }, null, 2) + "\n");
|
|
111
|
+
} catch (e) {
|
|
112
|
+
log(`[${c.id}] classify failed: ${(e as Error).message}`);
|
|
113
|
+
}
|
|
114
|
+
judgeCalls += b.stats().calls;
|
|
115
|
+
judgeCost = b.stats().costUsd;
|
|
116
|
+
}
|
|
117
|
+
// 2. judge: roast objections vs decisive objections
|
|
118
|
+
const cached = readJson<{ items: JudgeItem[]; calls: number; cost_usd: number | null }>(join(dir, "judge.json"));
|
|
119
|
+
const addCost = (x: number | null) => { judgeCost = judgeCost === null && x === null ? null : (judgeCost ?? 0) + (x ?? 0); };
|
|
120
|
+
if (cached) {
|
|
121
|
+
judge = cached.items;
|
|
122
|
+
judgeCalls += cached.calls;
|
|
123
|
+
addCost(cached.cost_usd);
|
|
124
|
+
} else if (o.judge !== false) {
|
|
125
|
+
const b = mk();
|
|
126
|
+
try {
|
|
127
|
+
judge = await runJudge(c, r, b, model);
|
|
128
|
+
writeFileSync(join(dir, "judge.json"), JSON.stringify({ items: judge, calls: b.stats().calls, cost_usd: b.stats().costUsd, model, dry_run: dry }, null, 2) + "\n");
|
|
129
|
+
log(`[${c.id}${dirs.length > 1 ? `/r${k}` : ""}] judged`);
|
|
130
|
+
} catch (e) {
|
|
131
|
+
log(`[${c.id}] judge failed: ${(e as Error).message}`);
|
|
132
|
+
}
|
|
133
|
+
judgeCalls += b.stats().calls;
|
|
134
|
+
addCost(b.stats().costUsd);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
scores.push(scoreCase(c, r, judge, corpusRows, {
|
|
138
|
+
repeat: k, error: r ? null : (err?.error?.split("\n")[0] ?? "no roast.json (not run yet)"),
|
|
139
|
+
judgeCalls, judgeCost, wallMs: meta?.wall_ms ?? null, classify,
|
|
140
|
+
lastShown: meta?.options?.last_shown_date ?? null,
|
|
141
|
+
precedentCutoff: meta?.options?.precedent_cutoff ?? null,
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const pick = (v: boolean | null) => scores.filter((s) => s.post_training_cutoff === v);
|
|
146
|
+
const byRepeat = repeats > 1
|
|
147
|
+
? Array.from({ length: repeats }, (_, i) => headline(scores.filter((s) => s.repeat === i + 1)))
|
|
148
|
+
: null;
|
|
149
|
+
const agreement = repeats > 1
|
|
150
|
+
? [...new Set(scores.map((s) => s.case_id))].map((id) => {
|
|
151
|
+
const vs = scores.filter((s) => s.case_id === id).map((s) => s.verdict);
|
|
152
|
+
const counts = new Map<string | null, number>();
|
|
153
|
+
for (const v of vs) counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
154
|
+
return { case_id: id, verdicts: vs, modal_share: Math.max(...counts.values()) / vs.length };
|
|
155
|
+
})
|
|
156
|
+
: null;
|
|
157
|
+
const costs = scores.map((s) => s.cost.cost_usd);
|
|
158
|
+
return {
|
|
159
|
+
run_id: o.runId,
|
|
160
|
+
scored_at: new Date().toISOString(),
|
|
161
|
+
dry_run: dryRun,
|
|
162
|
+
rules: "docs/eval.md",
|
|
163
|
+
cases_in_file: o.cases.length,
|
|
164
|
+
cases_scored: new Set(scores.map((s) => s.case_id)).size,
|
|
165
|
+
repeats,
|
|
166
|
+
headline: headline(scores),
|
|
167
|
+
by_post_training_cutoff: { true: headline(pick(true)), false: headline(pick(false)), unknown: headline(pick(null)) },
|
|
168
|
+
by_outcome: Object.fromEntries(["rejected", "accepted", "reverted"].map((k) => [k, headline(scores.filter((s) => s.outcome === k))])),
|
|
169
|
+
by_decisive_level: Object.fromEntries(["idea", "design", "implementation", "unclassified"].map((k) =>
|
|
170
|
+
[k, headline(scores.filter((s) => (s.decisive_level ?? "unclassified") === k))])),
|
|
171
|
+
by_repeat: byRepeat,
|
|
172
|
+
agreement,
|
|
173
|
+
scores,
|
|
174
|
+
totals: {
|
|
175
|
+
roast_calls: scores.reduce((a, s) => a + s.cost.roast_calls, 0),
|
|
176
|
+
judge_calls: scores.reduce((a, s) => a + s.cost.judge_calls, 0),
|
|
177
|
+
cost_usd: costs.every((c) => c === null) ? null : Math.round(costs.reduce<number>((a, c) => a + (c ?? 0), 0) * 100) / 100,
|
|
178
|
+
wall_ms: scores.reduce((a, s) => a + (s.cost.wall_ms ?? 0), 0),
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------- markdown
|
|
184
|
+
|
|
185
|
+
const pct = (r: { num: number; den: number; pct: number | null }) => (r.den ? `${r.num}/${r.den} (${r.pct}%)` : "n/a");
|
|
186
|
+
const yn = (b: boolean | null) => (b === null ? "–" : b ? "yes" : "no");
|
|
187
|
+
|
|
188
|
+
function headlineTable(rows: [string, Headline][]): string {
|
|
189
|
+
const h = "| | cases | (a) verdict lenient | (a) strict | (b) decisive found | (b) found or partial | (c) earlier found | (d) false alarms |\n|---|---:|---|---|---|---|---|---|\n";
|
|
190
|
+
return h + rows.map(([n, x]) =>
|
|
191
|
+
`| ${n} | ${x.cases}${x.errors ? ` (${x.errors} err)` : ""} | ${pct(x.verdict_lenient)} | ${pct(x.verdict_strict)} | ${pct(x.decisive_found)} | ${pct(x.decisive_found_or_partial)} | ${pct(x.earlier_found)} | ${pct(x.false_alarm_rate)} |`).join("\n") + "\n";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function renderReport(rep: Report, cases: EvalCase[]): string {
|
|
195
|
+
const byId = new Map(cases.map((c) => [c.id, c]));
|
|
196
|
+
const H = rep.headline;
|
|
197
|
+
let md = `# PG Roast eval: run \`${rep.run_id}\`\n\n`;
|
|
198
|
+
md += `Scored ${rep.scored_at}. Rules: [docs/eval.md](../../../docs/eval.md) (fixed before any result). `;
|
|
199
|
+
md += `${rep.cases_scored} of ${rep.cases_in_file} cases in this run${rep.repeats > 1 ? `, ${rep.repeats} repeats each` : ""}.\n\n`;
|
|
200
|
+
if (rep.dry_run) md += `> **Dry run**: placeholder verdicts from the DryRunBackend; these numbers mean nothing.\n\n`;
|
|
201
|
+
md += `## Headline\n\n`;
|
|
202
|
+
md += `- **(a) verdict match**: ${pct(H.verdict_lenient)} lenient, ${pct(H.verdict_strict)} strict\n`;
|
|
203
|
+
md += `- **(b) decisive objection found** (listed or other surviving): ${pct(H.decisive_found)}; found or partially: ${pct(H.decisive_found_or_partial)}; found but refuted by the critic: ${H.decisive_found_but_refuted}\n`;
|
|
204
|
+
md += `- **(c) earlier attempt found** (in prior attempts): ${pct(H.earlier_found)}; retrieved but not listed: ${H.earlier_retrieved_only}; not in corpus (corpus gap, excluded): ${H.earlier_not_in_corpus}\n`;
|
|
205
|
+
md += `- **(d) false-alarm rate on accepted cases**: ${pct(H.false_alarm_rate)}\n\n`;
|
|
206
|
+
md += `### Split by post_training_cutoff\n\nThe models may remember how famous old threads ended; post-cutoff cases cannot be recalled.\n\n`;
|
|
207
|
+
md += headlineTable([["all", H], ["post-cutoff (true)", rep.by_post_training_cutoff.true], ["pre-cutoff (false)", rep.by_post_training_cutoff.false], ...(rep.by_post_training_cutoff.unknown.cases ? [["unknown", rep.by_post_training_cutoff.unknown] as [string, Headline]] : [])]);
|
|
208
|
+
md += `\n### Split by outcome\n\n` + headlineTable(Object.entries(rep.by_outcome).filter(([, h]) => h.cases));
|
|
209
|
+
md += `\n### Split by level of the decisive objection\n\nLevel from a separate classify call that saw only the case, never the roast (idea = rubric 1a/1b, design = 2, implementation = 3-5). An implementation-level reason (a bug, a buildfarm failure) is usually invisible to an idea-level review of the first few emails; those cases are shown apart, not averaged in.\n\n`;
|
|
210
|
+
md += headlineTable(Object.entries(rep.by_decisive_level).filter(([, h]) => h.cases));
|
|
211
|
+
const inWin = rep.scores.filter((s) => s.decisive_in_window);
|
|
212
|
+
if (inWin.length) {
|
|
213
|
+
md += `\n> **Decisive objection inside the shown window** (position <= blind_n; the roast could read it): ${[...new Set(inWin.map((s) => s.case_id))].join(", ")}. A "found" here may be reading, not foresight.\n`;
|
|
214
|
+
}
|
|
215
|
+
if (rep.by_repeat) {
|
|
216
|
+
md += `\n### Per repeat\n\n` + headlineTable(rep.by_repeat.map((h, i) => [`r${i + 1}`, h] as [string, Headline]));
|
|
217
|
+
md += `\nVerdict agreement across repeats:\n\n| case | verdicts | modal share |\n|---|---|---:|\n`;
|
|
218
|
+
md += rep.agreement!.map((a) => `| ${a.case_id} | ${a.verdicts.map((v) => v ?? "error").join(", ")} | ${Math.round(a.modal_share * 100)}% |`).join("\n") + "\n";
|
|
219
|
+
}
|
|
220
|
+
md += `\n## Cases\n\n| case | outcome | post-cutoff | precedent cutoff | decisive level | verdict | (a) lenient | strict | (b) decisive | bucket | (c) earlier | fatal surviving | calls (roast+judge) | cost | wall |\n|---|---|---|---|---|---|---|---|---|---|---|---:|---|---:|---:|\n`;
|
|
221
|
+
for (const s of rep.scores) {
|
|
222
|
+
const tag = (rep.repeats > 1 ? `${s.case_id}/r${s.repeat}` : s.case_id) + (s.decisive_in_window ? " ⚑" : "");
|
|
223
|
+
const lvl = s.decisive_level ?? "–";
|
|
224
|
+
const co = s.cutoffs;
|
|
225
|
+
const cut = co.precedent_cutoff
|
|
226
|
+
? `${co.precedent_cutoff}${co.precedent_cutoff < co.case_cutoff_date ? ` (case ${co.case_cutoff_date}; last shown ${co.last_shown_date ?? "?"})` : ""}`
|
|
227
|
+
: `? (case ${co.case_cutoff_date})`;
|
|
228
|
+
if (s.error) {
|
|
229
|
+
md += `| ${tag} | ${s.outcome} | ${yn(s.post_training_cutoff)} | ${cut} | ${lvl} | ERROR: ${s.error.replace(/\|/g, "/").slice(0, 80)} | | | | | | | ${s.cost.roast_calls}+${s.cost.judge_calls} | | |\n`;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const d = s.decisive!;
|
|
233
|
+
md += `| ${tag} | ${s.outcome} | ${yn(s.post_training_cutoff)} | ${cut} | ${lvl} | ${s.verdict} (${s.confidence}) | ${yn(s.verdict_lenient)} | ${yn(s.verdict_strict)} | ${d.status === "scored" ? d.result : d.status} | ${d.bucket ?? "–"} | ${s.earlier!.status} | ${s.surviving_fatal} | ${s.cost.roast_calls}+${s.cost.judge_calls} | ${s.cost.cost_usd === null ? "–" : `$${s.cost.cost_usd.toFixed(2)}`} | ${s.cost.wall_ms === null ? "–" : `${Math.round(s.cost.wall_ms / 1000)} s`} |\n`;
|
|
234
|
+
}
|
|
235
|
+
const t = rep.totals;
|
|
236
|
+
md += `\n⚑ = decisive objection inside the shown window. Precedent cutoff = min(case cutoff_date, last shown message + 1 day); both dates are shown when the window made it earlier.\n`;
|
|
237
|
+
md += `\nTotals: ${t.roast_calls} roast calls + ${t.judge_calls} judge calls; cost ${t.cost_usd === null ? "not reported" : `$${t.cost_usd}`}; wall ${Math.round(t.wall_ms / 60000)} min (sum over cases).\n`;
|
|
238
|
+
|
|
239
|
+
md += `\n## Misses\n\n`;
|
|
240
|
+
const misses = rep.scores.filter((s) => !s.error && (s.verdict_lenient === false || (s.decisive?.status === "scored" && !s.decisive.found_surviving) || s.earlier?.status === "not_found" || s.earlier?.status === "retrieved_only"));
|
|
241
|
+
if (!misses.length) md += `None.\n`;
|
|
242
|
+
for (const s of misses) {
|
|
243
|
+
const c = byId.get(s.case_id)!;
|
|
244
|
+
md += `### ${s.case_id}${rep.repeats > 1 ? ` r${s.repeat}` : ""}: ${c.title}\n\n`;
|
|
245
|
+
if (s.verdict_lenient === false) md += `- **Verdict**: ${s.verdict}, but the outcome was ${s.outcome}.${s.false_alarm ? ` False alarm (${s.surviving_fatal} surviving fatal).` : ""}\n`;
|
|
246
|
+
if (s.decisive?.status === "scored" && !s.decisive.found_surviving) {
|
|
247
|
+
md += `- **Decisive objection** (${s.decisive_level ?? "unclassified"}-level${s.decisive_in_window ? ", inside the shown window" : ""}): ${s.decisive.result}${s.decisive.bucket ? ` (best match ${s.decisive.objection_id}, ${s.decisive.bucket})` : ""}. ${s.decisive.reason}\n`;
|
|
248
|
+
for (const [i, d] of c.decisive_objections.entries()) {
|
|
249
|
+
const lv = s.decisive_levels.find((x) => x.decisive_index === i + 1)?.level;
|
|
250
|
+
md += ` - D${i + 1}${lv ? ` [${lv}]` : ""} ${d.who ?? ""}${d.date ? `, ${d.date}` : ""}: "${String(d.quote ?? "").replace(/\s+/g, " ").slice(0, 300)}"${d.message_url ? ` ([message](${d.message_url}))` : d.source ? ` (source: ${String(d.source).slice(0, 120)})` : ""}\n`;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (s.earlier?.status === "not_found" || s.earlier?.status === "retrieved_only") {
|
|
254
|
+
md += `- **Earlier attempt** ${s.earlier.status === "retrieved_only" ? "retrieved but not listed as a prior attempt" : "not found"}: ${c.earlier_attempts.map((e) => e.thread_url).join(", ")}`;
|
|
255
|
+
md += ` (in corpus as ${s.earlier.in_corpus.map((x) => `${x.rows.join("/")}${x.retrievable_before_cutoff ? "" : ", dated on/after cutoff"}`).join("; ")})\n`;
|
|
256
|
+
}
|
|
257
|
+
md += `\n`;
|
|
258
|
+
}
|
|
259
|
+
return md;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function writeReport(rep: Report, cases: EvalCase[]): { md: string; json: string } {
|
|
263
|
+
const dir = join(RESULTS_DIR, rep.run_id);
|
|
264
|
+
const md = join(dir, "report.md"), json = join(dir, "report.json");
|
|
265
|
+
writeFileSync(json, JSON.stringify(rep, null, 2) + "\n");
|
|
266
|
+
writeFileSync(md, renderReport(rep, cases));
|
|
267
|
+
return { md, json };
|
|
268
|
+
}
|
package/src/eval/run.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Eval runner: roast each case in blind mode, as
|
|
2
|
+
// bun run src/cli.ts <thread_url> --blind <blind_n> [--rounds N]
|
|
3
|
+
// would. Messages: blind_n only. Precedent cutoff (roast `before`, set after
|
|
4
|
+
// loading so it filters no message) = min(cutoff_date, last shown message
|
|
5
|
+
// date + 1 day). Writes to
|
|
6
|
+
// eval/results/<run-id>/<case-id>[/r<k>]/. Resumable.
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { CliBackend, DryRunBackend, modelsFromEnv, pickBackend, type BackendName } from "../roast/llm.ts";
|
|
11
|
+
import { closePrecedents } from "../roast/precedents.ts";
|
|
12
|
+
import { runRoast } from "../roast/pipeline.ts";
|
|
13
|
+
import { renderCompactMarkdown, renderMarkdown } from "../roast/render.ts";
|
|
14
|
+
import { loadProposal } from "../thread/load.ts";
|
|
15
|
+
import { REPO_ROOT, type EvalCase } from "./cases.ts";
|
|
16
|
+
|
|
17
|
+
export const RESULTS_DIR = join(REPO_ROOT, "eval", "results");
|
|
18
|
+
|
|
19
|
+
export interface RunEvalOptions {
|
|
20
|
+
runId: string;
|
|
21
|
+
cases: EvalCase[];
|
|
22
|
+
/** pipeline reflection rounds, as `src/cli.ts --rounds` */
|
|
23
|
+
rounds?: number;
|
|
24
|
+
/** independent repetitions of each case (variance); default 1 */
|
|
25
|
+
repeat?: number;
|
|
26
|
+
concurrency?: number;
|
|
27
|
+
dryRun?: boolean;
|
|
28
|
+
backend?: BackendName;
|
|
29
|
+
offline?: boolean;
|
|
30
|
+
log?: (m: string) => void;
|
|
31
|
+
/** injected for tests */
|
|
32
|
+
roastFn?: typeof runRoast;
|
|
33
|
+
loadFn?: typeof loadProposal;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PrecedentCutoff {
|
|
37
|
+
/** the case file's cutoff_date */
|
|
38
|
+
case_cutoff_date: string;
|
|
39
|
+
/** UTC date of the last message shown to the roast */
|
|
40
|
+
last_shown_date: string;
|
|
41
|
+
/** what the roast used: the earlier of case_cutoff_date and last_shown_date + 1 day */
|
|
42
|
+
precedent_cutoff: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Precedent cutoff = min(cutoff_date, last shown message date + 1 day), so a
|
|
47
|
+
* case whose shown window is from 2018 gets no precedent dated 2019 or later.
|
|
48
|
+
* Precedents are kept only if every date they have is strictly before it.
|
|
49
|
+
*/
|
|
50
|
+
export function precedentCutoff(caseCutoff: string, lastShownIso: string): PrecedentCutoff {
|
|
51
|
+
const last = lastShownIso.slice(0, 10);
|
|
52
|
+
const d = new Date(last + "T00:00:00Z");
|
|
53
|
+
d.setUTCDate(d.getUTCDate() + 1);
|
|
54
|
+
const dayAfter = d.toISOString().slice(0, 10);
|
|
55
|
+
const cc = caseCutoff.slice(0, 10);
|
|
56
|
+
return { case_cutoff_date: cc, last_shown_date: last, precedent_cutoff: cc < dayAfter ? cc : dayAfter };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function caseDir(runId: string, caseId: string, repeat: number, repeats: number): string {
|
|
60
|
+
return repeats > 1 ? join(RESULTS_DIR, runId, caseId, `r${repeat}`) : join(RESULTS_DIR, runId, caseId);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface CaseMeta {
|
|
64
|
+
case_id: string;
|
|
65
|
+
repeat: number;
|
|
66
|
+
started_at: string;
|
|
67
|
+
wall_ms: number;
|
|
68
|
+
llm_calls: number;
|
|
69
|
+
cost_usd: number | null;
|
|
70
|
+
options: {
|
|
71
|
+
thread_url: string; blind: number; message_filter: string; rounds: number; hint: string | null; backend: string; models: unknown;
|
|
72
|
+
} & Partial<PrecedentCutoff>;
|
|
73
|
+
error: string | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function runEval(o: RunEvalOptions): Promise<{ done: number; skipped: number; failed: number }> {
|
|
77
|
+
const log = o.log ?? ((m: string) => console.error(m));
|
|
78
|
+
const repeats = Math.max(1, o.repeat ?? 1);
|
|
79
|
+
const backendName: BackendName = o.dryRun ? (o.backend ?? "claude") : pickBackend(o.backend);
|
|
80
|
+
const models = modelsFromEnv(process.env, backendName);
|
|
81
|
+
const roast = o.roastFn ?? runRoast;
|
|
82
|
+
const load = o.loadFn ?? loadProposal;
|
|
83
|
+
const jobs: { c: EvalCase; k: number }[] = [];
|
|
84
|
+
for (const c of o.cases) for (let k = 1; k <= repeats; k++) jobs.push({ c, k });
|
|
85
|
+
const res = { done: 0, skipped: 0, failed: 0 };
|
|
86
|
+
mkdirSync(join(RESULTS_DIR, o.runId), { recursive: true });
|
|
87
|
+
|
|
88
|
+
const one = async ({ c, k }: { c: EvalCase; k: number }) => {
|
|
89
|
+
const dir = caseDir(o.runId, c.id, k, repeats);
|
|
90
|
+
const tag = repeats > 1 ? `${c.id}/r${k}` : c.id;
|
|
91
|
+
if (existsSync(join(dir, "roast.json"))) {
|
|
92
|
+
res.skipped++;
|
|
93
|
+
log(`[${tag}] roast.json exists, skipped`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
mkdirSync(dir, { recursive: true });
|
|
97
|
+
rmSync(join(dir, "error.json"), { force: true });
|
|
98
|
+
const t0 = Date.now();
|
|
99
|
+
const transcriptDir = join(dir, "llm");
|
|
100
|
+
const backend = o.dryRun
|
|
101
|
+
? new DryRunBackend({ transcriptDir, print: () => undefined })
|
|
102
|
+
: new CliBackend({ backend: backendName, transcriptDir, log: (m) => log(`[${tag}] ${m}`) });
|
|
103
|
+
const meta: CaseMeta = {
|
|
104
|
+
case_id: c.id, repeat: k, started_at: new Date(t0).toISOString(), wall_ms: 0, llm_calls: 0, cost_usd: null,
|
|
105
|
+
options: {
|
|
106
|
+
thread_url: c.thread_url, blind: c.blind_n,
|
|
107
|
+
message_filter: "blind only (first N messages); cutoff_date not applied to messages", rounds: o.rounds ?? 0,
|
|
108
|
+
hint: null, backend: o.dryRun ? "dry-run" : backendName, models,
|
|
109
|
+
},
|
|
110
|
+
error: null,
|
|
111
|
+
};
|
|
112
|
+
try {
|
|
113
|
+
log(`[${tag}] loading ${c.thread_url} (first ${c.blind_n} message(s))`);
|
|
114
|
+
// Messages: blind_n only (first N of the flat thread). cutoff_date is the
|
|
115
|
+
// precedent cutoff only: set after loading so it filters no message.
|
|
116
|
+
const proposal = await load(c.thread_url, { blind: c.blind_n, offline: o.offline });
|
|
117
|
+
const lastShown = proposal.thread.messages.at(-1)?.date ?? proposal.threadStart;
|
|
118
|
+
const pc = precedentCutoff(c.cutoff_date, lastShown);
|
|
119
|
+
proposal.filters.before = pc.precedent_cutoff;
|
|
120
|
+
Object.assign(meta.options, pc);
|
|
121
|
+
log(`[${tag}] precedent cutoff ${pc.precedent_cutoff} (case cutoff ${pc.case_cutoff_date}, last shown message ${pc.last_shown_date})`);
|
|
122
|
+
const r = await roast(proposal, {
|
|
123
|
+
backend, models, backendName: o.dryRun ? "dry-run" : backendName, hint: null, enrich: true,
|
|
124
|
+
mode: "review", rounds: o.rounds ?? 0, log: (m) => log(`[${tag}] ${m}`),
|
|
125
|
+
});
|
|
126
|
+
r.stats.wall_ms = Date.now() - t0;
|
|
127
|
+
writeFileSync(join(dir, "roast.md"), renderCompactMarkdown(r));
|
|
128
|
+
writeFileSync(join(dir, "roast-full.md"), renderMarkdown(r));
|
|
129
|
+
// roast.json last: its presence marks the case done
|
|
130
|
+
writeFileSync(join(dir, "roast.json"), JSON.stringify(r, null, 2) + "\n");
|
|
131
|
+
Object.assign(meta, { wall_ms: r.stats.wall_ms, llm_calls: r.stats.llm_calls, cost_usd: r.stats.cost_usd });
|
|
132
|
+
res.done++;
|
|
133
|
+
log(`[${tag}] verdict ${r.verdict} (${r.confidence}); ${r.stats.llm_calls} calls; ${Math.round(r.stats.wall_ms / 1000)} s`);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
meta.error = String((e as Error)?.stack ?? e);
|
|
136
|
+
meta.wall_ms = Date.now() - t0;
|
|
137
|
+
meta.llm_calls = backend.stats().calls;
|
|
138
|
+
meta.cost_usd = backend.stats().costUsd;
|
|
139
|
+
writeFileSync(join(dir, "error.json"), JSON.stringify({ error: meta.error }, null, 2) + "\n");
|
|
140
|
+
res.failed++;
|
|
141
|
+
log(`[${tag}] FAILED: ${(e as Error)?.message ?? e}`);
|
|
142
|
+
}
|
|
143
|
+
writeFileSync(join(dir, "case-meta.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const queue = [...jobs];
|
|
147
|
+
const workers = Array.from({ length: Math.max(1, Math.min(o.concurrency ?? 2, jobs.length || 1)) }, async () => {
|
|
148
|
+
for (let j = queue.shift(); j; j = queue.shift()) await one(j);
|
|
149
|
+
});
|
|
150
|
+
await Promise.all(workers);
|
|
151
|
+
await closePrecedents();
|
|
152
|
+
return res;
|
|
153
|
+
}
|