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,201 @@
|
|
|
1
|
+
// Precedent search. Imported directly by the roaster.
|
|
2
|
+
//
|
|
3
|
+
// 1. AND path: websearch_to_tsquery('english', q), ranked by ts_rank_cd over
|
|
4
|
+
// the weighted tsv (title A, summary+tags B, objection C).
|
|
5
|
+
// 2. If that yields fewer than `limit` rows: OR-of-terms fallback — the
|
|
6
|
+
// query's english lexemes, each prefix-matched, OR'ed (to_tsquery('simple')
|
|
7
|
+
// so the already-stemmed lexemes are not stemmed again), expanded with the
|
|
8
|
+
// small SYNONYMS map, ranked by the number of distinct query terms covered
|
|
9
|
+
// (title matches count twice), then ts_rank_cd.
|
|
10
|
+
// 3. If pg_trgm is installed: similarity(title, q) is added to the score and
|
|
11
|
+
// titles with similarity > 0.3 are also candidates.
|
|
12
|
+
// Only stored rows are returned, so every URL is one the build read from a source.
|
|
13
|
+
|
|
14
|
+
import type { SQL } from "bun";
|
|
15
|
+
import { connect, hasTrgm } from "./db.ts";
|
|
16
|
+
import type { Kind } from "./util.ts";
|
|
17
|
+
|
|
18
|
+
export interface Precedent {
|
|
19
|
+
id: string;
|
|
20
|
+
kind: Kind;
|
|
21
|
+
title: string;
|
|
22
|
+
status: string;
|
|
23
|
+
date: string | null; // decision date, YYYY-MM-DD
|
|
24
|
+
first_date: string | null; // thread start / reverted commit, YYYY-MM-DD
|
|
25
|
+
url: string;
|
|
26
|
+
thread_urls: string[];
|
|
27
|
+
authors: string[];
|
|
28
|
+
reviewers: string[];
|
|
29
|
+
objectors: string[];
|
|
30
|
+
tags: string;
|
|
31
|
+
summary: string;
|
|
32
|
+
objection: string;
|
|
33
|
+
touch_points: string[];
|
|
34
|
+
score: number;
|
|
35
|
+
match: "and" | "or" | "trgm";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SearchOptions {
|
|
39
|
+
query: string;
|
|
40
|
+
/** YYYY-MM-DD: keep only rows whose every known date is strictly before this. */
|
|
41
|
+
before?: string;
|
|
42
|
+
kinds?: Kind[];
|
|
43
|
+
limit?: number;
|
|
44
|
+
/** With `before`: also keep rows that have no date at all (wiki items). */
|
|
45
|
+
includeUndated?: boolean;
|
|
46
|
+
sql?: SQL;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// words that carry no signal in proposal titles; dropped in the OR fallback only
|
|
50
|
+
const WEAK = new Set(["add", "adding", "support", "allow", "new", "option", "use", "make", "patch", "feature", "pg"]);
|
|
51
|
+
|
|
52
|
+
// Small curated synonym map over english-stemmed lexemes, used in the OR
|
|
53
|
+
// fallback only. A query term counts as covered if it or any synonym matches.
|
|
54
|
+
export const SYNONYMS: Record<string, string[]> = {
|
|
55
|
+
planner: ["optim"],
|
|
56
|
+
optim: ["planner"],
|
|
57
|
+
pooler: ["pool", "pgbouncer", "pgpool"],
|
|
58
|
+
pool: ["pooler", "pgbouncer", "pgpool"],
|
|
59
|
+
posit: ["order", "reorder", "attnum"],
|
|
60
|
+
timestamp: ["time", "date"],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// SQL keywords that the english config drops as stop words; when present in
|
|
64
|
+
// the query they are matched against the title in the 'simple' config (tsv_kw).
|
|
65
|
+
export const KEYWORD_STOPWORDS = new Set(["where", "having", "between", "into", "only", "any", "all", "off", "over"]);
|
|
66
|
+
|
|
67
|
+
export interface OrTerm {
|
|
68
|
+
/** 'tsv' = weighted english vector; 'kw' = title in the simple config */
|
|
69
|
+
c: "tsv" | "kw";
|
|
70
|
+
/** tsquery text (simple config) matching anywhere in the vector */
|
|
71
|
+
q: string;
|
|
72
|
+
/** same, title (weight A) only */
|
|
73
|
+
tq: string;
|
|
74
|
+
label: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Terms of the OR fallback. Built only from english lexemes, SQL keywords and
|
|
79
|
+
* identifiers that match [a-z0-9]+, so no user text reaches tsquery syntax.
|
|
80
|
+
*/
|
|
81
|
+
export function orTerms(query: string, lexemes: string[]): OrTerm[] {
|
|
82
|
+
const terms: OrTerm[] = [];
|
|
83
|
+
for (const l of lexemes) {
|
|
84
|
+
const group = [l, ...(SYNONYMS[l] ?? [])];
|
|
85
|
+
terms.push({
|
|
86
|
+
c: "tsv", label: l,
|
|
87
|
+
q: group.map((x) => `'${x}':*`).join(" | "),
|
|
88
|
+
tq: group.map((x) => `'${x}':*A`).join(" | "),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const words = query.toLowerCase().match(/[a-z0-9_]+/g) ?? [];
|
|
92
|
+
for (const w of new Set(words)) {
|
|
93
|
+
if (KEYWORD_STOPWORDS.has(w)) terms.push({ c: "kw", label: w, q: `'${w}'`, tq: `'${w}'` });
|
|
94
|
+
const parts = w.split("_").filter(Boolean);
|
|
95
|
+
if (parts.length > 1 && parts.every((x) => /^[a-z0-9]+$/.test(x))) {
|
|
96
|
+
const ph = parts.map((x) => `'${x}'`).join(" <-> ");
|
|
97
|
+
terms.push({ c: "kw", label: w, q: ph, tq: ph });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return terms;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let shared: SQL | null = null;
|
|
104
|
+
let trgmCache: boolean | null = null;
|
|
105
|
+
|
|
106
|
+
export async function closeSearch() {
|
|
107
|
+
if (shared) await shared.close();
|
|
108
|
+
shared = null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function searchPrecedents(o: SearchOptions): Promise<Precedent[]> {
|
|
112
|
+
const sql = o.sql ?? (shared ??= connect());
|
|
113
|
+
const limit = Math.max(1, Math.min(o.limit ?? 10, 200));
|
|
114
|
+
const q = o.query.trim();
|
|
115
|
+
if (!q) return [];
|
|
116
|
+
if (o.before && !/^\d{4}-\d\d-\d\d$/.test(o.before)) throw new Error("before must be YYYY-MM-DD");
|
|
117
|
+
if (trgmCache === null || o.sql) trgmCache = await hasTrgm(sql);
|
|
118
|
+
const trgm = trgmCache;
|
|
119
|
+
const kinds = o.kinds?.length ? o.kinds : null;
|
|
120
|
+
const before = o.before ?? null;
|
|
121
|
+
const undated = !!o.includeUndated;
|
|
122
|
+
|
|
123
|
+
const filters = sql`
|
|
124
|
+
(${kinds === null} or p.kind in (select jsonb_array_elements_text((${JSON.stringify(kinds ?? [])}::text)::jsonb)))
|
|
125
|
+
and (${before}::date is null
|
|
126
|
+
or (coalesce(p.first_date, p.date) < ${before}::date and coalesce(p.date, p.first_date) < ${before}::date)
|
|
127
|
+
or (${undated} and p.first_date is null and p.date is null))`;
|
|
128
|
+
const trgmExpr = trgm ? sql`similarity(p.title, ${q})` : sql`0::real`;
|
|
129
|
+
const cols = sql`
|
|
130
|
+
p.id, p.kind, p.title, p.status, to_char(p.date, 'YYYY-MM-DD') as date,
|
|
131
|
+
to_char(p.first_date, 'YYYY-MM-DD') as first_date, p.url,
|
|
132
|
+
array_to_json(p.thread_urls)::text as thread_urls, array_to_json(p.authors)::text as authors,
|
|
133
|
+
array_to_json(p.reviewers)::text as reviewers, array_to_json(p.objectors)::text as objectors,
|
|
134
|
+
p.tags, p.summary, p.objection, array_to_json(p.touch_points)::text as touch_points`;
|
|
135
|
+
|
|
136
|
+
const andRows = await sql`
|
|
137
|
+
select ${cols}, ts_rank_cd(p.tsv, w.q, 1) + 0.5 * ${trgmExpr} as score, 'and' as match
|
|
138
|
+
from precedent p, websearch_to_tsquery('english', ${q}) w(q)
|
|
139
|
+
where numnode(w.q) > 0 and p.tsv @@ w.q and ${filters}
|
|
140
|
+
order by score desc, p.id limit ${limit}`;
|
|
141
|
+
let rows = [...andRows];
|
|
142
|
+
|
|
143
|
+
if (rows.length < limit) {
|
|
144
|
+
const lex = await sql`
|
|
145
|
+
select coalesce(array_agg(l order by l), '{}') as lexemes
|
|
146
|
+
from unnest(tsvector_to_array(to_tsvector('english', ${q}))) l
|
|
147
|
+
where l ~ '^[[:alnum:]_.-]+$'`;
|
|
148
|
+
const lexemes = ((lex[0]?.lexemes ?? []) as string[]).filter((l) => !WEAK.has(l));
|
|
149
|
+
const seen = rows.map((r) => r.id as string);
|
|
150
|
+
const terms = orTerms(q, lexemes);
|
|
151
|
+
if (terms.length) {
|
|
152
|
+
// document frequency per term -> idf weight
|
|
153
|
+
const tj = JSON.stringify(terms);
|
|
154
|
+
const df = await sql`
|
|
155
|
+
select (select count(*) from precedent)::float8 as n,
|
|
156
|
+
coalesce(json_agg((select count(*) from precedent p where
|
|
157
|
+
case when t->>'c' = 'kw' then p.tsv_kw @@ to_tsquery('simple', t->>'q')
|
|
158
|
+
else p.tsv @@ to_tsquery('simple', t->>'q') end) order by o), '[]')::text as df
|
|
159
|
+
from jsonb_array_elements((${tj}::text)::jsonb) with ordinality as x(t, o)`;
|
|
160
|
+
const n = Number(df[0]!.n);
|
|
161
|
+
const dfs = JSON.parse(df[0]!.df) as number[];
|
|
162
|
+
const weighted = JSON.stringify(terms.map((t, i) => ({ ...t, w: Math.log((n + 1) / ((dfs[i] ?? 0) + 1)) })));
|
|
163
|
+
// score = sum over terms of idf * (matches anywhere + matches in title) + ts_rank_cd
|
|
164
|
+
const cover = sql`(select coalesce(sum((t->>'w')::float8 * (
|
|
165
|
+
(case when t->>'c' = 'kw' then p.tsv_kw @@ to_tsquery('simple', t->>'q')
|
|
166
|
+
else p.tsv @@ to_tsquery('simple', t->>'q') end)::int
|
|
167
|
+
+ (case when t->>'c' = 'kw' then p.tsv_kw @@ to_tsquery('simple', t->>'q')
|
|
168
|
+
else p.tsv @@ to_tsquery('simple', t->>'tq') end)::int)), 0)
|
|
169
|
+
from jsonb_array_elements((${weighted}::text)::jsonb) t)`;
|
|
170
|
+
const anyq = terms.filter((t) => t.c === "tsv").map((t) => t.q).join(" | ");
|
|
171
|
+
const orRows = await sql`
|
|
172
|
+
select * from (
|
|
173
|
+
select ${cols}, ${cover}
|
|
174
|
+
+ coalesce(ts_rank_cd(p.tsv, to_tsquery('simple', nullif(${anyq}, '')), 1), 0)
|
|
175
|
+
+ 0.5 * ${trgmExpr} as score, 'or' as match
|
|
176
|
+
from precedent p
|
|
177
|
+
where ${filters}
|
|
178
|
+
and p.id not in (select jsonb_array_elements_text((${JSON.stringify(seen)}::text)::jsonb))
|
|
179
|
+
) s
|
|
180
|
+
where s.score > 0.0001
|
|
181
|
+
order by score desc, id limit ${limit - rows.length}`;
|
|
182
|
+
rows = rows.concat([...orRows]);
|
|
183
|
+
}
|
|
184
|
+
if (trgm && rows.length < limit) {
|
|
185
|
+
const seen2 = rows.map((r) => r.id as string);
|
|
186
|
+
const tRows = await sql`
|
|
187
|
+
select ${cols}, 0.5 * similarity(p.title, ${q}) as score, 'trgm' as match
|
|
188
|
+
from precedent p
|
|
189
|
+
where similarity(p.title, ${q}) > 0.3 and ${filters} and p.id not in (select jsonb_array_elements_text((${JSON.stringify(seen2)}::text)::jsonb))
|
|
190
|
+
order by score desc, p.id limit ${limit - rows.length}`;
|
|
191
|
+
rows = rows.concat([...tRows]);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return rows.map((r) => ({
|
|
195
|
+
id: r.id, kind: r.kind, title: r.title, status: r.status, date: r.date, first_date: r.first_date,
|
|
196
|
+
url: r.url, thread_urls: JSON.parse(r.thread_urls), authors: JSON.parse(r.authors),
|
|
197
|
+
reviewers: JSON.parse(r.reviewers), objectors: JSON.parse(r.objectors), tags: r.tags,
|
|
198
|
+
summary: r.summary, objection: r.objection, touch_points: JSON.parse(r.touch_points),
|
|
199
|
+
score: Math.round(Number(r.score) * 10000) / 10000, match: r.match,
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Shared helpers: HTML text extraction, message-id links, touch-point rules,
|
|
2
|
+
// and the record type every source produces.
|
|
3
|
+
|
|
4
|
+
export type Kind = "cf" | "revert" | "do_not_want";
|
|
5
|
+
|
|
6
|
+
/** One row as produced by a source extractor (before it is stored). */
|
|
7
|
+
export interface PrecedentRecord {
|
|
8
|
+
id: string;
|
|
9
|
+
kind: Kind;
|
|
10
|
+
rawRef: string; // stable source key; unique per kind
|
|
11
|
+
title: string;
|
|
12
|
+
status: string;
|
|
13
|
+
date: string | null; // decision date, ISO "YYYY-MM-DD[THH:MM:SS]"
|
|
14
|
+
firstDate: string | null;
|
|
15
|
+
url: string;
|
|
16
|
+
threadUrls: string[];
|
|
17
|
+
authors: string[];
|
|
18
|
+
reviewers: string[];
|
|
19
|
+
objectors: string[];
|
|
20
|
+
tags: string;
|
|
21
|
+
summary: string;
|
|
22
|
+
objection: string;
|
|
23
|
+
touchPoints: string[];
|
|
24
|
+
otherLinks: string[];
|
|
25
|
+
provenance: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const NAMED: Record<string, string> = {
|
|
29
|
+
amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", ndash: "–", mdash: "—",
|
|
30
|
+
hellip: "…", laquo: "«", raquo: "»", lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”",
|
|
31
|
+
copy: "©", eacute: "é", oslash: "ø", ouml: "ö", uuml: "ü", auml: "ä", aacute: "á",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function unescapeHtml(s: string): string {
|
|
35
|
+
return s.replace(/&(#x[0-9a-fA-F]+|#\d+|[A-Za-z]+);/g, (m, e: string) => {
|
|
36
|
+
if (e[0] === "#") {
|
|
37
|
+
const cp = e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
|
|
38
|
+
return Number.isFinite(cp) ? String.fromCodePoint(cp) : m;
|
|
39
|
+
}
|
|
40
|
+
return NAMED[e] ?? m;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function stripTags(s: string): string {
|
|
45
|
+
return unescapeHtml(s.replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " "))
|
|
46
|
+
.replace(/[ \t\r\f\v\u00a0]+/g, " ")
|
|
47
|
+
.replace(/\s*\n\s*/g, "\n")
|
|
48
|
+
.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** "a (x), b (y)" -> ["a (x)", "b (y)"] */
|
|
52
|
+
export function splitPeople(s: string): string[] {
|
|
53
|
+
return s.split(/,\s*(?![^()]*\))/).map((x) => x.trim()).filter(Boolean);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------- message-ids
|
|
57
|
+
|
|
58
|
+
export const FLAT = "https://www.postgresql.org/message-id/flat/";
|
|
59
|
+
const SAFE = /[A-Za-z0-9@+=._\-~!$&'*,;:]/;
|
|
60
|
+
|
|
61
|
+
function safeDecode(s: string): string {
|
|
62
|
+
try {
|
|
63
|
+
return decodeURIComponent(s);
|
|
64
|
+
} catch {
|
|
65
|
+
return s;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Canonical form of a message-id inside a flat URL (decode, then re-quote). */
|
|
70
|
+
export function canonMsgid(raw: string): string {
|
|
71
|
+
const mid = safeDecode(raw.trim()).replace(/^<|>$/g, "").trim();
|
|
72
|
+
let out = "";
|
|
73
|
+
for (const ch of mid) {
|
|
74
|
+
if (SAFE.test(ch)) out += ch;
|
|
75
|
+
else for (const b of new TextEncoder().encode(ch)) out += "%" + b.toString(16).toUpperCase().padStart(2, "0");
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const flatUrl = (rawMsgid: string) => FLAT + canonMsgid(rawMsgid);
|
|
81
|
+
|
|
82
|
+
const MSGID_URL_RE =
|
|
83
|
+
/https?:\/\/(?:www\.)?postgresql\.org\/message-id\/(?:flat\/|raw\/|mbox\/)?([^\s"'<>()\[\]]+)|https?:\/\/postgr\.es\/m\/([^\s"'<>()\[\]]+)/g;
|
|
84
|
+
|
|
85
|
+
export function isMsgidUrl(u: string): boolean {
|
|
86
|
+
return /^https?:\/\/(?:www\.)?postgresql\.org\/message-id\/|^https?:\/\/postgr\.es\/m\//.test(u);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Raw message-ids found as URLs in text, in order, deduped. */
|
|
90
|
+
export function extractMsgids(text: string): string[] {
|
|
91
|
+
const out: string[] = [];
|
|
92
|
+
for (const m of text.matchAll(MSGID_URL_RE)) {
|
|
93
|
+
const mid = (m[1] ?? m[2] ?? "").replace(/[.,;:]+$/, "");
|
|
94
|
+
if (!mid || mid.startsWith("attachment/")) continue;
|
|
95
|
+
if (!safeDecode(mid).includes("@")) continue;
|
|
96
|
+
if (!out.includes(mid)) out.push(mid);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------- touch points
|
|
102
|
+
// Cheap deterministic keyword rules (no LLM). Hints only.
|
|
103
|
+
|
|
104
|
+
const TOUCH_RULES: [string, RegExp][] = [
|
|
105
|
+
["pg_dump / restore", /\bpg_dump(all)?\b|\bpg_restore\b|\bdump\b/i],
|
|
106
|
+
["psql", /\bpsql\b/i],
|
|
107
|
+
["new GUC", /\bgucs?\b|\bpostgresql\.conf\b|\bconfiguration (parameter|variable|option)/i],
|
|
108
|
+
["WAL / replication / standby", /\bwal\b|replication|\bstandby\b|\breplica\b|\bwalsender\b|\bwalreceiver\b|\bfailover\b|logical decoding|\bslot\b|\brecovery\b|\barchiv/i],
|
|
109
|
+
["planner", /\bplanner\b|\boptimi[sz]er\b|\bplan\b|\bexplain\b|\bjoin\b|\bcost(ing)?\b|\bhint/i],
|
|
110
|
+
["wire protocol / libpq", /\bprotocol\b|\blibpq\b|\bwire\b/i],
|
|
111
|
+
["catalog changes", /\bcatalog\b|\bpg_class\b|\bpg_attribute\b|\bsystem view\b|\bpg_proc\b/i],
|
|
112
|
+
["on-disk format / pg_upgrade", /\bpg_upgrade\b|on-disk|\bpage format\b|\btuple format\b|\bheap format\b/i],
|
|
113
|
+
["security / privileges", /\bsecurity\b|\bprivilege|\bgrant\b|\brevoke\b|\brole\b|\bpassword\b|\bauth|\bssl\b|\btls\b|\bscram\b|\brls\b|row.level security/i],
|
|
114
|
+
["SQL syntax / standard", /\bsyntax\b|sql standard|\bsql:20\d\d\b|\bkeyword\b|\bgrammar\b/i],
|
|
115
|
+
["ABI / extension API", /\bhook\b|\bextension\b|\babi\b|\btable am\b|\bindex am\b|\bcustom scan\b/i],
|
|
116
|
+
["performance on the hot path", /\bperformance\b|\bspeed ?up\b|\bfaster\b|\bhot path\b|\blatency\b/i],
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
export function touchPoints(text: string): string[] {
|
|
120
|
+
return TOUCH_RULES.filter(([, rx]) => rx.test(text)).map(([n]) => n);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** "2024-09-12 11:08:15" -> "2024-09-12T11:08:15" */
|
|
124
|
+
export function normTs(s: string | null | undefined): string | null {
|
|
125
|
+
if (!s) return null;
|
|
126
|
+
const m = /(\d{4}-\d\d-\d\d)(?:[ T](\d\d:\d\d(?::\d\d)?))?/.exec(s);
|
|
127
|
+
if (!m) return null;
|
|
128
|
+
return m[1] + (m[2] ? "T" + m[2] : "");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function log(msg: string) {
|
|
132
|
+
console.error(msg);
|
|
133
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Wiki source: https://wiki.postgresql.org/wiki/Not_Worth_Doing
|
|
2
|
+
// "Features We Do Not Want" is an <h2> on that page (not on wiki/Todo; the
|
|
3
|
+
// Todo page says retired items move to Not_Worth_Doing). Items under that
|
|
4
|
+
// heading get status "Not wanted"; the other retired items "Not worth doing".
|
|
5
|
+
|
|
6
|
+
import { fetchPage } from "./fetch.ts";
|
|
7
|
+
import {
|
|
8
|
+
extractMsgids, flatUrl, isMsgidUrl, stripTags, touchPoints, unescapeHtml,
|
|
9
|
+
type PrecedentRecord,
|
|
10
|
+
} from "./util.ts";
|
|
11
|
+
|
|
12
|
+
export const WIKI_PAGE = "https://wiki.postgresql.org/wiki/Not_Worth_Doing";
|
|
13
|
+
export const DNW_HEADING = "Features We Do Not Want";
|
|
14
|
+
|
|
15
|
+
export interface WikiItem {
|
|
16
|
+
anchor: string; section: string; index: number; title: string;
|
|
17
|
+
description: string; links: string[]; linkTexts: string[]; wikiLinks: string[]; doNotWant: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseWiki(h: string): WikiItem[] {
|
|
21
|
+
let body = h.slice(Math.max(0, h.indexOf('id="mw-content-text"')));
|
|
22
|
+
const cat = body.indexOf('id="catlinks"');
|
|
23
|
+
if (cat >= 0) body = body.slice(0, cat);
|
|
24
|
+
// headings with "/" carry a URL-safe legacy anchor span first; prefer it
|
|
25
|
+
const re = /<h([23])>(?:<span id="([^"]+)"><\/span>)?<span class="mw-headline" id="([^"]+)">([\s\S]*?)<\/span><\/h[23]>/g;
|
|
26
|
+
const heads = [...body.matchAll(re)];
|
|
27
|
+
const items: WikiItem[] = [];
|
|
28
|
+
let h2: string | null = null;
|
|
29
|
+
heads.forEach((m, i) => {
|
|
30
|
+
const level = m[1], anchor = m[2] ?? m[3]!, label = stripTags(m[4]!);
|
|
31
|
+
if (level === "2") h2 = label;
|
|
32
|
+
const start = m.index! + m[0].length;
|
|
33
|
+
const end = heads[i + 1]?.index ?? body.length;
|
|
34
|
+
const content = body.slice(start, end);
|
|
35
|
+
const section = label === h2 ? label : `${h2} / ${label}`;
|
|
36
|
+
let n = 0;
|
|
37
|
+
for (const dm of content.matchAll(/<dl>\s*<dt[^>]*>([\s\S]*?)<\/dt>\s*<dd>([\s\S]*?)<\/dd>\s*<\/dl>/g)) {
|
|
38
|
+
n++;
|
|
39
|
+
const dt = dm[1]!, dd = dm[2]!;
|
|
40
|
+
const both = dt + dd;
|
|
41
|
+
items.push({
|
|
42
|
+
anchor, section, index: n,
|
|
43
|
+
title: stripTags(dt.split("<ul>")[0]!),
|
|
44
|
+
description: stripTags(dd.replace(/<ul>[\s\S]*?<\/ul>/g, "")),
|
|
45
|
+
links: [...both.matchAll(/href="(https?:\/\/[^"]+)"/g)].map((x) => unescapeHtml(x[1]!)),
|
|
46
|
+
linkTexts: [...both.matchAll(/<a [^>]*href="https?:\/\/[^"]+"[^>]*>([\s\S]*?)<\/a>/g)].map((x) => stripTags(x[1]!)),
|
|
47
|
+
wikiLinks: [...dd.matchAll(/href="(\/wiki\/(?!File:)[^"#]+)"/g)].map((x) => "https://wiki.postgresql.org" + unescapeHtml(x[1]!)),
|
|
48
|
+
doNotWant: h2 === DNW_HEADING,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
return items;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function collectWiki(opts: { offline?: boolean }) {
|
|
56
|
+
const h = await fetchPage(WIKI_PAGE, opts);
|
|
57
|
+
if (!h) throw new Error("cannot fetch " + WIKI_PAGE);
|
|
58
|
+
const items = parseWiki(h);
|
|
59
|
+
const recs: PrecedentRecord[] = items.map((it) => {
|
|
60
|
+
const note = it.linkTexts.filter((t) => t && !t.startsWith("http")).join("; ");
|
|
61
|
+
return {
|
|
62
|
+
id: `wiki:${it.anchor}:${it.index}`, kind: "do_not_want", rawRef: `Not_Worth_Doing#${it.anchor}:${it.index}`,
|
|
63
|
+
title: it.title, status: it.doNotWant ? "Not wanted" : "Not worth doing",
|
|
64
|
+
date: null, firstDate: null, url: `${WIKI_PAGE}#${it.anchor}`,
|
|
65
|
+
threadUrls: [...new Set(extractMsgids(it.links.join(" ")).map(flatUrl))],
|
|
66
|
+
authors: [], reviewers: [], objectors: [],
|
|
67
|
+
tags: it.section,
|
|
68
|
+
summary: `${it.title}\nSection: ${it.section}` + (note ? `\nLinked: ${note}` : ""),
|
|
69
|
+
objection: it.description,
|
|
70
|
+
touchPoints: touchPoints([it.title, it.section, it.description].join(" ")),
|
|
71
|
+
otherLinks: [...it.links.filter((u) => !isMsgidUrl(u)), ...it.wikiLinks],
|
|
72
|
+
provenance: { source: "wiki page " + WIKI_PAGE, section: it.section, item_index: it.index },
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
recs,
|
|
77
|
+
cov: { items: items.length, do_not_want_items: items.filter((i) => i.doNotWant).length },
|
|
78
|
+
complete: true,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Eval cases: eval/cases.json (owned by the evalset agent). This loader is
|
|
2
|
+
// tolerant: it accepts the documented fields, reports problems per case, and
|
|
3
|
+
// keeps unknown fields so the report can show them.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
import { canonMsgid } from "../corpus/util.ts";
|
|
8
|
+
|
|
9
|
+
export const REPO_ROOT = resolve(import.meta.dir, "..", "..");
|
|
10
|
+
export const CASES_PATH = join(REPO_ROOT, "eval", "cases.json");
|
|
11
|
+
|
|
12
|
+
export type Outcome = "rejected" | "accepted" | "reverted";
|
|
13
|
+
|
|
14
|
+
export interface DecisiveObjection {
|
|
15
|
+
who?: string;
|
|
16
|
+
date?: string;
|
|
17
|
+
message_url?: string;
|
|
18
|
+
quote?: string;
|
|
19
|
+
category?: string;
|
|
20
|
+
/** where the quote comes from when there is no message_url (e.g. a revert commit message) */
|
|
21
|
+
source?: string;
|
|
22
|
+
[k: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface EarlierAttempt {
|
|
26
|
+
thread_url: string;
|
|
27
|
+
[k: string]: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface EvalCase {
|
|
31
|
+
id: string;
|
|
32
|
+
title: string;
|
|
33
|
+
outcome: Outcome;
|
|
34
|
+
thread_url: string;
|
|
35
|
+
first_message_id?: string;
|
|
36
|
+
cutoff_date: string;
|
|
37
|
+
blind_n: number;
|
|
38
|
+
decisive_objections: DecisiveObjection[];
|
|
39
|
+
earlier_attempts: EarlierAttempt[];
|
|
40
|
+
post_training_cutoff: boolean | null;
|
|
41
|
+
commit?: string | null;
|
|
42
|
+
revert_commit?: string | null;
|
|
43
|
+
/** 1-based position of the decisive message in the flat thread, if known. */
|
|
44
|
+
decisive_objection_position?: number | null;
|
|
45
|
+
/** Hint for directed precedent lookup, only if the case file provides one. */
|
|
46
|
+
hint?: string | null;
|
|
47
|
+
[k: string]: unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const OUTCOMES = new Set(["rejected", "accepted", "reverted"]);
|
|
51
|
+
|
|
52
|
+
/** Parse cases; throws on a file-level problem, returns per-case problems. */
|
|
53
|
+
export function parseCases(json: unknown): { cases: EvalCase[]; problems: string[] } {
|
|
54
|
+
const arr = Array.isArray(json) ? json : (json as { cases?: unknown })?.cases;
|
|
55
|
+
if (!Array.isArray(arr)) throw new Error("cases.json: expected an array or {cases: [...]}");
|
|
56
|
+
const cases: EvalCase[] = [];
|
|
57
|
+
const problems: string[] = [];
|
|
58
|
+
const ids = new Set<string>();
|
|
59
|
+
arr.forEach((raw, i) => {
|
|
60
|
+
const c = raw as Record<string, unknown>;
|
|
61
|
+
const where = `case[${i}]${typeof c?.id === "string" ? ` (${c.id})` : ""}`;
|
|
62
|
+
const bad: string[] = [];
|
|
63
|
+
if (typeof c?.id !== "string" || !c.id) bad.push("id missing");
|
|
64
|
+
else if (ids.has(c.id)) bad.push("duplicate id");
|
|
65
|
+
const outcome = typeof c?.outcome === "string" ? c.outcome.toLowerCase() : "";
|
|
66
|
+
if (!OUTCOMES.has(outcome)) bad.push(`outcome must be rejected|accepted|reverted, got ${JSON.stringify(c?.outcome)}`);
|
|
67
|
+
if (typeof c?.thread_url !== "string" || !c.thread_url) bad.push("thread_url missing");
|
|
68
|
+
if (typeof c?.cutoff_date !== "string" || !/^\d{4}-\d\d-\d\d/.test(c.cutoff_date)) bad.push("cutoff_date missing or not YYYY-MM-DD");
|
|
69
|
+
const blind = Number(c?.blind_n);
|
|
70
|
+
if (!Number.isInteger(blind) || blind < 1) bad.push("blind_n must be a positive integer");
|
|
71
|
+
if (bad.length) {
|
|
72
|
+
problems.push(`${where}: ${bad.join("; ")}`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
ids.add(c.id as string);
|
|
76
|
+
const ptc = c.post_training_cutoff;
|
|
77
|
+
cases.push({
|
|
78
|
+
...c,
|
|
79
|
+
id: c.id as string,
|
|
80
|
+
title: typeof c.title === "string" ? c.title : (c.id as string),
|
|
81
|
+
outcome: outcome as Outcome,
|
|
82
|
+
thread_url: c.thread_url as string,
|
|
83
|
+
cutoff_date: (c.cutoff_date as string).slice(0, 10),
|
|
84
|
+
blind_n: blind,
|
|
85
|
+
decisive_objections: Array.isArray(c.decisive_objections) ? (c.decisive_objections as DecisiveObjection[]) : [],
|
|
86
|
+
earlier_attempts: (Array.isArray(c.earlier_attempts) ? (c.earlier_attempts as EarlierAttempt[]) : [])
|
|
87
|
+
.filter((e) => e && typeof e.thread_url === "string" && e.thread_url),
|
|
88
|
+
post_training_cutoff: typeof ptc === "boolean" ? ptc : null,
|
|
89
|
+
decisive_objection_position: Number.isInteger(c.decisive_objection_position) ? (c.decisive_objection_position as number) : null,
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
return { cases, problems };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function loadCases(path = CASES_PATH): { cases: EvalCase[]; problems: string[] } {
|
|
96
|
+
if (!existsSync(path)) throw new Error(`${path} does not exist yet`);
|
|
97
|
+
return parseCases(JSON.parse(readFileSync(path, "utf8")));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Message-id of a thread/message URL (or a bare id), in the corpus's
|
|
102
|
+
* canonical form: the part after /message-id/[flat|raw|mbox/] or postgr.es/m/,
|
|
103
|
+
* without query/fragment, percent-decoded, <> stripped, re-quoted.
|
|
104
|
+
*/
|
|
105
|
+
export function msgidOf(urlOrId: string): string | null {
|
|
106
|
+
let s = urlOrId.trim();
|
|
107
|
+
const m = /(?:postgresql\.org\/message-id\/(?:flat\/|raw\/|mbox\/)?|postgr\.es\/m\/)([^?#\s]+)/.exec(s);
|
|
108
|
+
if (m) s = m[1]!;
|
|
109
|
+
else if (/^https?:/.test(s)) return null;
|
|
110
|
+
s = s.replace(/\/+$/, "");
|
|
111
|
+
const c = canonMsgid(s);
|
|
112
|
+
return c.includes("@") ? c : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** True when the decisive objection's message is inside the shown window (position <= blind_n). */
|
|
116
|
+
export function decisiveInWindow(c: EvalCase): boolean {
|
|
117
|
+
return c.decisive_objection_position != null && c.decisive_objection_position <= c.blind_n;
|
|
118
|
+
}
|
package/src/eval/cli.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// PG Roast evaluation CLI (rules: docs/eval.md).
|
|
2
|
+
//
|
|
3
|
+
// bun run src/eval/cli.ts run [--run-id ID] [--case ID ...] [--rounds N] [--repeat K] [--concurrency 2] [--dry-run] [--backend claude|codex] [--offline]
|
|
4
|
+
// bun run src/eval/cli.ts score <run-id> [--no-judge] [--case ID ...]
|
|
5
|
+
// bun run src/eval/cli.ts list
|
|
6
|
+
|
|
7
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import type { BackendName } from "../roast/llm.ts";
|
|
10
|
+
import { CASES_PATH, loadCases, type EvalCase } from "./cases.ts";
|
|
11
|
+
import { renderReport, scoreRun, writeReport } from "./report.ts";
|
|
12
|
+
import { RESULTS_DIR, runEval } from "./run.ts";
|
|
13
|
+
|
|
14
|
+
const USAGE = `usage:
|
|
15
|
+
bun run src/eval/cli.ts run [--run-id ID] [--case ID ...] [--rounds N] [--repeat K] [--concurrency 2] [--dry-run] [--backend claude|codex] [--offline] [--cases FILE]
|
|
16
|
+
bun run src/eval/cli.ts score <run-id> [--no-judge] [--case ID ...] [--backend claude|codex] [--cases FILE]
|
|
17
|
+
bun run src/eval/cli.ts list [--cases FILE]
|
|
18
|
+
--rounds N passes through to the pipeline (extra reflection rounds, as src/cli.ts --rounds).
|
|
19
|
+
--repeat K runs every case K times (r1..rK) to measure variance.`;
|
|
20
|
+
|
|
21
|
+
interface Args { cmd: string; pos: string[]; cases: string[]; flags: Record<string, string | true> }
|
|
22
|
+
|
|
23
|
+
export function parseArgs(argv: string[]): Args {
|
|
24
|
+
const a: Args = { cmd: argv[0] ?? "", pos: [], cases: [], flags: {} };
|
|
25
|
+
const boolFlags = new Set(["dry-run", "no-judge", "offline", "help"]);
|
|
26
|
+
for (let i = 1; i < argv.length; i++) {
|
|
27
|
+
const t = argv[i]!;
|
|
28
|
+
if (t.startsWith("--")) {
|
|
29
|
+
const k = t.slice(2);
|
|
30
|
+
if (boolFlags.has(k)) { a.flags[k] = true; continue; }
|
|
31
|
+
const v = argv[++i];
|
|
32
|
+
if (v === undefined) throw new Error(`--${k} needs a value`);
|
|
33
|
+
if (k === "case") a.cases.push(...v.split(","));
|
|
34
|
+
else a.flags[k] = v;
|
|
35
|
+
} else a.pos.push(t);
|
|
36
|
+
}
|
|
37
|
+
return a;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function selectCases(all: EvalCase[], ids: string[]): EvalCase[] {
|
|
41
|
+
if (!ids.length) return all;
|
|
42
|
+
const unknown = ids.filter((id) => !all.some((c) => c.id === id));
|
|
43
|
+
if (unknown.length) throw new Error(`unknown case id(s): ${unknown.join(", ")}`);
|
|
44
|
+
return all.filter((c) => ids.includes(c.id));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const int = (v: string | true | undefined, def: number, name: string, min = 0, max = 100) => {
|
|
48
|
+
if (v === undefined) return def;
|
|
49
|
+
const n = Number(v);
|
|
50
|
+
if (!Number.isInteger(n) || n < min || n > max) throw new Error(`--${name} must be an integer ${min}..${max}`);
|
|
51
|
+
return n;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
async function main(): Promise<number> {
|
|
55
|
+
const a = parseArgs(process.argv.slice(2));
|
|
56
|
+
if (!a.cmd || a.flags.help) { console.log(USAGE); return a.cmd ? 0 : 2; }
|
|
57
|
+
const casesPath = typeof a.flags.cases === "string" ? a.flags.cases : CASES_PATH;
|
|
58
|
+
const { cases, problems } = loadCases(casesPath);
|
|
59
|
+
for (const p of problems) console.error(`cases.json: skipped ${p}`);
|
|
60
|
+
|
|
61
|
+
if (a.cmd === "list") {
|
|
62
|
+
for (const c of cases) {
|
|
63
|
+
console.log(`${c.id.padEnd(24)} ${c.outcome.padEnd(9)} blind ${String(c.blind_n).padStart(3)} before ${c.cutoff_date} post-cutoff ${c.post_training_cutoff ?? "?"} decisive ${c.decisive_objections.length} earlier ${c.earlier_attempts.length} ${c.title}`);
|
|
64
|
+
}
|
|
65
|
+
const runs = existsSync(RESULTS_DIR) ? readdirSync(RESULTS_DIR).filter((d) => !d.startsWith(".")) : [];
|
|
66
|
+
console.log(`\nruns: ${runs.join(", ") || "(none)"}`);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
if (a.cmd === "run") {
|
|
70
|
+
const runId = typeof a.flags["run-id"] === "string" ? a.flags["run-id"] : new Date().toISOString().slice(0, 16).replace(/[-:]/g, "").replace("T", "-");
|
|
71
|
+
if (!/^[A-Za-z0-9._-]+$/.test(runId)) throw new Error("--run-id: letters, digits, . _ - only");
|
|
72
|
+
const sel = selectCases(cases, a.cases);
|
|
73
|
+
console.error(`run ${runId}: ${sel.length} case(s) -> ${join(RESULTS_DIR, runId)}${a.flags["dry-run"] ? " (DRY RUN, no model calls)" : ""}`);
|
|
74
|
+
const r = await runEval({
|
|
75
|
+
runId, cases: sel,
|
|
76
|
+
rounds: int(a.flags.rounds, 0, "rounds", 0, 6),
|
|
77
|
+
repeat: int(a.flags.repeat, 1, "repeat", 1, 10),
|
|
78
|
+
concurrency: int(a.flags.concurrency, 2, "concurrency", 1, 8),
|
|
79
|
+
dryRun: !!a.flags["dry-run"],
|
|
80
|
+
backend: a.flags.backend as BackendName | undefined,
|
|
81
|
+
offline: !!a.flags.offline,
|
|
82
|
+
});
|
|
83
|
+
console.error(`run ${runId}: ${r.done} done, ${r.skipped} skipped (already done), ${r.failed} failed`);
|
|
84
|
+
console.error(`next: bun run src/eval/cli.ts score ${runId}`);
|
|
85
|
+
return r.failed ? 1 : 0;
|
|
86
|
+
}
|
|
87
|
+
if (a.cmd === "score") {
|
|
88
|
+
const runId = a.pos[0];
|
|
89
|
+
if (!runId) throw new Error("score needs a run id");
|
|
90
|
+
const rep = await scoreRun({
|
|
91
|
+
runId, cases: selectCases(cases, a.cases), judge: !a.flags["no-judge"],
|
|
92
|
+
backend: a.flags.backend as BackendName | undefined,
|
|
93
|
+
});
|
|
94
|
+
const out = writeReport(rep, cases);
|
|
95
|
+
console.log(renderReport(rep, cases).split("\n## Cases")[0]);
|
|
96
|
+
console.error(`wrote ${out.md}\nwrote ${out.json}`);
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
console.error(USAGE);
|
|
100
|
+
return 2;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (import.meta.main) {
|
|
104
|
+
main().then((c) => process.exit(c), (e) => { console.error(`eval: ${(e as Error).message}`); process.exit(1); });
|
|
105
|
+
}
|