@gamaze/hicortex 0.16.0 → 0.16.1
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/README.md +1 -0
- package/dist/consolidate.js +4 -1
- package/dist/distiller.js +19 -12
- package/dist/eval/relevance-eval.d.ts +64 -0
- package/dist/eval/relevance-eval.js +1954 -0
- package/dist/index.js +3 -2
- package/dist/lessons-context.js +3 -2
- package/dist/mcp-server.js +1 -0
- package/dist/prompts.js +22 -6
- package/dist/recall-index.d.ts +43 -4
- package/dist/recall-index.js +40 -8
- package/dist/seed-lesson.d.ts +1 -1
- package/dist/seed-lesson.js +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,1954 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* Real-query relevance + SNIPPET eval — recall QUALITY on real agent prompts.
|
|
5
|
+
*
|
|
6
|
+
* v2 (spec `specs/2026-08-02-relevance-eval.md`) extends the v1 selection-only
|
|
7
|
+
* eval with the SNIPPET layer: v1 asked "did retrieve() surface the right
|
|
8
|
+
* memories?" (judge sees up to 2000 chars). Production shows the agent only a
|
|
9
|
+
* ~100-char one-liner (`recall-index.ts#memoryTitle`), so a memory can be
|
|
10
|
+
* genuinely relevant while its rendered line is useless — v1 scored that as a
|
|
11
|
+
* win. v2 grades BOTH: `full_verdict` (selection quality, judge sees full
|
|
12
|
+
* content) and `line_verdict` (snippet quality, judge sees ONLY the rendered
|
|
13
|
+
* production one-liner — imported from `recall-index.ts`, never reimplemented).
|
|
14
|
+
*
|
|
15
|
+
* v2 additions (spec §4, §5, §6, §6b) layered onto the v1 base (prompt
|
|
16
|
+
* sampling, readonly snapshot handling, embed-once + neverCalledEmbed,
|
|
17
|
+
* lenient JSON parse, distribution/CI reporting):
|
|
18
|
+
* 1. Dual verdict per surfaced memory — TWO separate, blind judge calls.
|
|
19
|
+
* 2. Snippet-length sweep (100/200/300/title+1st-sentence) on a fixed
|
|
20
|
+
* 40-prompt subset (8 per source).
|
|
21
|
+
* 3. Similarity-floor + retrieval-source analysis (near-free — logged, not
|
|
22
|
+
* re-judged).
|
|
23
|
+
* 4. Token-cost estimate (char/4) per K and per snippet-length variant.
|
|
24
|
+
* 5. ~20 rendered ACTUAL production blocks dumped into the report.
|
|
25
|
+
* 6. Redundancy — one set-level judge call per (prompt × mode) over the
|
|
26
|
+
* production 6.
|
|
27
|
+
* 7. Rate limiting + resumability (§6b, MANDATORY): serial calls,
|
|
28
|
+
* `--judge-delay-ms` (default 2000), exponential backoff with jitter on
|
|
29
|
+
* 429/5xx/timeout (5→10→20→40→80s, max 5 retries, respects
|
|
30
|
+
* `Retry-After`), checkpoint-per-call to a `.jsonl` sidecar, `--resume`,
|
|
31
|
+
* progress logging, 10%-error-rate abort, `--max-calls` budget guard
|
|
32
|
+
* (default 900).
|
|
33
|
+
*
|
|
34
|
+
* Prompt corpus (spec §2, owner decision §11.1): EVEN split, 20 prompts per
|
|
35
|
+
* source × 5 sources — Hermes (lenny, raider, nano) + CC (the DevOps
|
|
36
|
+
* `infrastructure` project, the `aironic-marine` project). Saved to
|
|
37
|
+
* `data/prompts.json`, stable/reused verbatim once a valid v2 set exists.
|
|
38
|
+
*
|
|
39
|
+
* Judge: GLM-5.2 via z.ai — the INSTRUMENT only. It never picks candidates;
|
|
40
|
+
* retrieve() (LLM-free) does. A dedicated raw HTTP caller (NOT `LlmClient`) is
|
|
41
|
+
* used here on purpose: `LlmClient.completeReflect` bakes in a
|
|
42
|
+
* nightly-tolerant retry policy (30s/60s/120s, unlimited rate-limit patience)
|
|
43
|
+
* that conflicts with §6b's specific real-time batch policy (5/10/20/40/80s +
|
|
44
|
+
* jitter, 5 retries, a hard call budget). Implemented directly here rather
|
|
45
|
+
* than adding a second retry mode to `llm.ts` (out of scope for this eval,
|
|
46
|
+
* and another agent is concurrently working elsewhere in this repo).
|
|
47
|
+
*
|
|
48
|
+
* Honesty invariants (non-negotiable — mirror recall-sweep.ts + spec §7):
|
|
49
|
+
* - Snapshot opened READONLY via openSnapshot — never initDb.
|
|
50
|
+
* - noStrengthen: true on every retrieve() call.
|
|
51
|
+
* - Real bge-small-en-v1.5 embedder, embed-once + queryEmbedding reuse.
|
|
52
|
+
* - neverCalledEmbed self-check ABORTS the run if retrieve() ignores
|
|
53
|
+
* queryEmbedding (would invalidate every measured number).
|
|
54
|
+
* - GLM-5.2 is the JUDGE only, real prompts, no synthetic queries.
|
|
55
|
+
* - Production renderer (`formatIndexLine`/`memoryTitle`) imported from
|
|
56
|
+
* `recall-index.ts`, never reimplemented.
|
|
57
|
+
* - judge_error batches/units excluded from every denominator, reported
|
|
58
|
+
* separately.
|
|
59
|
+
*
|
|
60
|
+
* Run:
|
|
61
|
+
* npm run eval:relevance -- <snapshot.db> [prompts.json] [report.md] \
|
|
62
|
+
* [--judge-delay-ms=2000] [--max-calls=900] [--resume] \
|
|
63
|
+
* [--verdicts-json=path] [--verdicts-jsonl=path]
|
|
64
|
+
*/
|
|
65
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
66
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
67
|
+
};
|
|
68
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
69
|
+
const node_fs_1 = require("node:fs");
|
|
70
|
+
const node_path_1 = require("node:path");
|
|
71
|
+
const node_os_1 = require("node:os");
|
|
72
|
+
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
73
|
+
const eval_db_js_1 = require("./eval-db.js");
|
|
74
|
+
const embedder_js_1 = require("../embedder.js");
|
|
75
|
+
const retrieval_js_1 = require("../retrieval.js");
|
|
76
|
+
const recall_index_js_1 = require("../recall-index.js");
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Constants
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
const RESULT_K = 8; // top-k replayed per prompt — the MEASUREMENT WINDOW. Two
|
|
81
|
+
// slots PAST the production menu (PROD_MENU_K=6) on purpose: the bubble
|
|
82
|
+
// (Q7-Q8) is cap-tuning data.
|
|
83
|
+
// All three mirror the shipped defaults in recall-index.ts (kept in sync
|
|
84
|
+
// manually — CLAUDE.md). Updated 2026-08-03 alongside the recall-quality PR.
|
|
85
|
+
const PROD_MENU_K = 5; // the shipped `recallMaxItems` default (was 6).
|
|
86
|
+
// K is a reporting/labelling param here (precision@4/@6/@8 all derive from
|
|
87
|
+
// the same stored verdict rows), so prior runs stay fully comparable.
|
|
88
|
+
const PROD_MIN_SIMILARITY = 0.62; // the shipped `recallMinSimilarity` floor (was 0.55).
|
|
89
|
+
const PROD_TITLE_CHARS = 100; // the shipped `recallTitleChars` default (was 150).
|
|
90
|
+
// Methodology note: this controls what the judge SEES in the baseline
|
|
91
|
+
// line_verdict pass, so eval #4's snippet_failure_rate is NOT directly
|
|
92
|
+
// comparable to #3's 24.3% (judged at 150). Cross-run comparability survives
|
|
93
|
+
// because SWEEP_VARIANTS still measures 150 on the 40-prompt subset — do NOT
|
|
94
|
+
// prune 150 from SWEEP_VARIANTS without recording that it breaks the #3↔#4
|
|
95
|
+
// comparison (lower N, wider CI, but comparable).
|
|
96
|
+
const MIN_PROMPT_CHARS = 20; // drop trivial/short prompts (<20 chars)
|
|
97
|
+
const MAX_MEM_CHARS_IN_JUDGE = 2000; // full_verdict per-memory cap.
|
|
98
|
+
const ZAI_BASE_URL = "https://api.z.ai/api/anthropic";
|
|
99
|
+
const ZAI_MODEL = "glm-5.2";
|
|
100
|
+
const N_PER_SOURCE = 20; // spec §2, §11.1: even 20/source.
|
|
101
|
+
const SWEEP_N_PER_SOURCE = 8; // spec §4.2: fixed 40-prompt subset (8/source).
|
|
102
|
+
const DEFAULT_JUDGE_DELAY_MS = 2000; // spec §6b: 1.5-2s between calls.
|
|
103
|
+
const BACKOFF_SCHEDULE_MS = [5_000, 10_000, 20_000, 40_000, 80_000]; // spec §6b.
|
|
104
|
+
const ERROR_RATE_ABORT_THRESHOLD = 0.10; // spec §6b.
|
|
105
|
+
const ERROR_RATE_MIN_SAMPLE = 20; // don't abort on noise from a tiny sample.
|
|
106
|
+
const DEFAULT_MAX_CALLS = 900; // spec §6b budget guard.
|
|
107
|
+
const DEFAULT_CC_PROJECTS_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "projects");
|
|
108
|
+
const PROMPTS_SAVE_PATH = (0, node_path_1.join)(process.cwd(), "data", "prompts.json");
|
|
109
|
+
const DEFAULT_REPORT_PATH = (0, node_path_1.join)(process.cwd(), "data", "relevance-eval-report.md");
|
|
110
|
+
const VERDICTS_JSON_PATH = (0, node_path_1.join)(process.cwd(), "data", "relevance-verdicts.json");
|
|
111
|
+
const VERDICTS_JSONL_PATH = (0, node_path_1.join)(process.cwd(), "data", "relevance-verdicts.jsonl");
|
|
112
|
+
/** Spec §2 — the 5 sources, in stable corpus order. */
|
|
113
|
+
const SOURCE_TAGS = [
|
|
114
|
+
"hermes-lenny",
|
|
115
|
+
"hermes-raider",
|
|
116
|
+
"hermes-nano",
|
|
117
|
+
"cc-infrastructure",
|
|
118
|
+
"cc-marine",
|
|
119
|
+
];
|
|
120
|
+
/** Required Hermes profile state DBs (flat /tmp layout, read-only, owner drop). */
|
|
121
|
+
const HERMES_PROFILE_DBS = {
|
|
122
|
+
lenny: "/tmp/hermes-lenny-state.db",
|
|
123
|
+
raider: "/tmp/hermes-raider-state.db",
|
|
124
|
+
nano: "/tmp/hermes-nano-state.db",
|
|
125
|
+
};
|
|
126
|
+
/** Profile → declared mission domains (corpus-derived, owner directive — see
|
|
127
|
+
* v1 header history). Simulates the per-agent config not yet set on bedrock;
|
|
128
|
+
* the scope-ON sweep measures what #203 domain affinity WOULD do. */
|
|
129
|
+
const AGENT_MISSION_DOMAINS = {
|
|
130
|
+
lenny: ["Health", "Ventures"],
|
|
131
|
+
nano: ["Finances", "Ventures"],
|
|
132
|
+
raider: ["Ventures"],
|
|
133
|
+
};
|
|
134
|
+
/** CC project dirs named in spec §2. "aironic-marine" matches BOTH the
|
|
135
|
+
* AironicVentures and GAV checkouts — collected together as ONE source
|
|
136
|
+
* ("cc-marine"), since the spec says "the `*aironic-marine*` DIRS" (plural). */
|
|
137
|
+
const CC_INFRA_DIR = (0, node_path_1.join)(DEFAULT_CC_PROJECTS_DIR, "-Users-mattias-Development-DevOps-infrastructure");
|
|
138
|
+
const CC_MARINE_DIRS = [
|
|
139
|
+
(0, node_path_1.join)(DEFAULT_CC_PROJECTS_DIR, "-Users-mattias-Development-AironicVentures-aironic-marine"),
|
|
140
|
+
(0, node_path_1.join)(DEFAULT_CC_PROJECTS_DIR, "-Users-mattias-Development-GAV-aironic-marine"),
|
|
141
|
+
];
|
|
142
|
+
/** Hermes session `source` values that are NOT primary conversations (cron =
|
|
143
|
+
* scheduled tasks, automated scans) — their "user" turns are boilerplate
|
|
144
|
+
* ("Run daily scan") and would inject synthetic-feeling prompts, undermining
|
|
145
|
+
* spec §1's "real prompts, not synthetic" requirement. Excluded, same as v1. */
|
|
146
|
+
const NON_PRIMARY_HERMES_SOURCES = new Set(["cron"]);
|
|
147
|
+
const SWEEP_VARIANTS = ["100", "150", "title1sent"];
|
|
148
|
+
const SWEEP_KS = [4, 6, 8]; // K-sweep (supporting table, §5.1).
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Prompt sampling — spec §2: 20/source × 5 sources (3 Hermes + 2 CC)
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
function normalizeForDedupe(s) {
|
|
153
|
+
return s.toLowerCase().replace(/\s+/g, " ").trim();
|
|
154
|
+
}
|
|
155
|
+
/** Pull user prompts from ONE Hermes profile state.db (flat /tmp path).
|
|
156
|
+
* Read-only; missing/locked/schema-drifted DB returns []. */
|
|
157
|
+
function collectHermesPrompts(dbPath, profile, sourceTag) {
|
|
158
|
+
const out = [];
|
|
159
|
+
const missionDomains = AGENT_MISSION_DOMAINS[profile] ?? ["Work"];
|
|
160
|
+
let db;
|
|
161
|
+
try {
|
|
162
|
+
db = new better_sqlite3_1.default(dbPath, { readonly: true, fileMustExist: true });
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const sessions = db
|
|
169
|
+
.prepare("SELECT id FROM sessions WHERE ended_at IS NOT NULL AND (source IS NULL OR source NOT IN ('cron'))")
|
|
170
|
+
.all();
|
|
171
|
+
const stmt = db.prepare("SELECT content FROM messages WHERE session_id = ? AND role = 'user' AND content IS NOT NULL ORDER BY id");
|
|
172
|
+
for (const s of sessions) {
|
|
173
|
+
const rows = stmt.all(s.id);
|
|
174
|
+
for (const r of rows) {
|
|
175
|
+
const text = (r.content ?? "").trim();
|
|
176
|
+
if (text.length < MIN_PROMPT_CHARS)
|
|
177
|
+
continue;
|
|
178
|
+
out.push({ prompt: text, agent: profile, sourceTag, missionDomains, project: null, source: dbPath });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
try {
|
|
187
|
+
db.close();
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
/* already closed */
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
/** Decode a CC project directory name to a project name (last dash-split
|
|
196
|
+
* token) — mirrors transcript-reader.ts's decodeProjectDirName. */
|
|
197
|
+
function decodeProjectName(dirName) {
|
|
198
|
+
const parts = dirName.split("-").filter(Boolean);
|
|
199
|
+
return parts.length === 0 ? dirName : parts[parts.length - 1];
|
|
200
|
+
}
|
|
201
|
+
function extractUserText(content) {
|
|
202
|
+
if (typeof content === "string")
|
|
203
|
+
return content;
|
|
204
|
+
if (!Array.isArray(content))
|
|
205
|
+
return "";
|
|
206
|
+
const texts = [];
|
|
207
|
+
for (const block of content) {
|
|
208
|
+
if (typeof block !== "object" || block === null)
|
|
209
|
+
continue;
|
|
210
|
+
if (block.type === "text") {
|
|
211
|
+
texts.push(String(block.text ?? ""));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return texts.join("\n").trim();
|
|
215
|
+
}
|
|
216
|
+
/** Collect every user prompt from ONE CC project dir's .jsonl session files. */
|
|
217
|
+
function collectCcPromptsFromDir(projectDir, sourceTag) {
|
|
218
|
+
const out = [];
|
|
219
|
+
let files;
|
|
220
|
+
try {
|
|
221
|
+
if (!(0, node_fs_1.statSync)(projectDir).isDirectory())
|
|
222
|
+
return [];
|
|
223
|
+
files = (0, node_fs_1.readdirSync)(projectDir);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return [];
|
|
227
|
+
}
|
|
228
|
+
const project = decodeProjectName(projectDir.split("/").filter(Boolean).pop() ?? projectDir);
|
|
229
|
+
for (const file of files) {
|
|
230
|
+
if (!file.endsWith(".jsonl"))
|
|
231
|
+
continue;
|
|
232
|
+
const filePath = (0, node_path_1.join)(projectDir, file);
|
|
233
|
+
let raw;
|
|
234
|
+
try {
|
|
235
|
+
raw = (0, node_fs_1.readFileSync)(filePath, "utf-8");
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
for (const line of raw.split("\n")) {
|
|
241
|
+
if (!line.trim())
|
|
242
|
+
continue;
|
|
243
|
+
let entry;
|
|
244
|
+
try {
|
|
245
|
+
entry = JSON.parse(line);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (typeof entry !== "object" || entry === null)
|
|
251
|
+
continue;
|
|
252
|
+
const e = entry;
|
|
253
|
+
const nested = e.message;
|
|
254
|
+
const role = String(e.type ?? nested?.role ?? "");
|
|
255
|
+
if (role !== "user")
|
|
256
|
+
continue;
|
|
257
|
+
const text = extractUserText(e.content ?? nested?.content).trim();
|
|
258
|
+
if (text.length < MIN_PROMPT_CHARS)
|
|
259
|
+
continue;
|
|
260
|
+
out.push({
|
|
261
|
+
prompt: text,
|
|
262
|
+
agent: `cc-${project}`,
|
|
263
|
+
sourceTag,
|
|
264
|
+
missionDomains: [],
|
|
265
|
+
project,
|
|
266
|
+
source: filePath,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
/** Dedupe (normalized text) + sort length DESC (prefer substantial prompts,
|
|
273
|
+
* matching v1's heuristic) + take the first `n`. Logs a shortfall loudly —
|
|
274
|
+
* never pads with synthetic content. */
|
|
275
|
+
function pickTopN(all, n, sourceTag) {
|
|
276
|
+
const seen = new Set();
|
|
277
|
+
const deduped = [];
|
|
278
|
+
for (const p of all) {
|
|
279
|
+
const key = normalizeForDedupe(p.prompt);
|
|
280
|
+
if (seen.has(key))
|
|
281
|
+
continue;
|
|
282
|
+
seen.add(key);
|
|
283
|
+
deduped.push(p);
|
|
284
|
+
}
|
|
285
|
+
deduped.sort((a, b) => b.prompt.length - a.prompt.length);
|
|
286
|
+
const picked = deduped.slice(0, n);
|
|
287
|
+
if (picked.length < n) {
|
|
288
|
+
console.warn(`[relevance-eval] WARNING: source "${sourceTag}" yielded only ${picked.length}/${n} prompts ` +
|
|
289
|
+
`after dedupe — corpus will be short by ${n - picked.length}. Not padded with synthetic content.`);
|
|
290
|
+
}
|
|
291
|
+
return picked;
|
|
292
|
+
}
|
|
293
|
+
/** Fail loudly if a required Hermes profile DB is missing — this is a
|
|
294
|
+
* precondition (task instructions), not a soft corpus shortfall. */
|
|
295
|
+
function verifyHermesPreconditions() {
|
|
296
|
+
const missing = Object.keys(HERMES_PROFILE_DBS).filter((p) => !(0, node_fs_1.existsSync)(HERMES_PROFILE_DBS[p]));
|
|
297
|
+
if (missing.length > 0) {
|
|
298
|
+
const lines = missing.map((p) => ` ssh agents@bedrock 'sqlite3 ~/.hermes/profiles/${p}/state.db ".backup /tmp/hermes-${p}-state.db"' && ` +
|
|
299
|
+
`scp agents@bedrock:/tmp/hermes-${p}-state.db ${HERMES_PROFILE_DBS[p]}`);
|
|
300
|
+
throw new Error(`relevance-eval: missing required Hermes state DB(s) for: ${missing.join(", ")}.\n` +
|
|
301
|
+
`Create via a consistent SQLite backup:\n${lines.join("\n")}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/** Build the v2 corpus fresh: 20 prompts/source × 5 sources (spec §2). */
|
|
305
|
+
function buildV2Corpus() {
|
|
306
|
+
verifyHermesPreconditions();
|
|
307
|
+
const buckets = [];
|
|
308
|
+
for (const [profile, dbPath] of Object.entries(HERMES_PROFILE_DBS)) {
|
|
309
|
+
const sourceTag = `hermes-${profile}`;
|
|
310
|
+
const all = collectHermesPrompts(dbPath, profile, sourceTag);
|
|
311
|
+
buckets.push(...pickTopN(all, N_PER_SOURCE, sourceTag));
|
|
312
|
+
}
|
|
313
|
+
const infraAll = collectCcPromptsFromDir(CC_INFRA_DIR, "cc-infrastructure");
|
|
314
|
+
buckets.push(...pickTopN(infraAll, N_PER_SOURCE, "cc-infrastructure"));
|
|
315
|
+
const marineAll = CC_MARINE_DIRS.flatMap((d) => collectCcPromptsFromDir(d, "cc-marine"));
|
|
316
|
+
buckets.push(...pickTopN(marineAll, N_PER_SOURCE, "cc-marine"));
|
|
317
|
+
if (buckets.length === 0) {
|
|
318
|
+
throw new Error("relevance-eval: corpus construction yielded 0 prompts across all 5 sources — aborting.");
|
|
319
|
+
}
|
|
320
|
+
return buckets;
|
|
321
|
+
}
|
|
322
|
+
/** A saved prompts.json satisfies the v2 corpus contract iff every one of the
|
|
323
|
+
* 5 sources is present (count may be < 20/source if a source was exhausted —
|
|
324
|
+
* see pickTopN's logged shortfall — but ALL 5 sources must be represented,
|
|
325
|
+
* never silently missing one). */
|
|
326
|
+
function isValidV2PromptSet(prompts) {
|
|
327
|
+
if (prompts.length === 0)
|
|
328
|
+
return false;
|
|
329
|
+
const present = new Set(prompts.map((p) => p.sourceTag));
|
|
330
|
+
return SOURCE_TAGS.every((t) => present.has(t));
|
|
331
|
+
}
|
|
332
|
+
function loadPromptsJson(path) {
|
|
333
|
+
const raw = (0, node_fs_1.readFileSync)(path, "utf-8");
|
|
334
|
+
const parsed = JSON.parse(raw);
|
|
335
|
+
if (!Array.isArray(parsed))
|
|
336
|
+
throw new Error(`relevance-eval: ${path} is not a JSON array`);
|
|
337
|
+
return parsed.map((p, i) => {
|
|
338
|
+
if (typeof p !== "object" || p === null)
|
|
339
|
+
throw new Error(`relevance-eval: ${path}[${i}] is not an object`);
|
|
340
|
+
const o = p;
|
|
341
|
+
const prompt = o.prompt;
|
|
342
|
+
if (typeof prompt !== "string")
|
|
343
|
+
throw new Error(`relevance-eval: ${path}[${i}] missing string prompt`);
|
|
344
|
+
const project = typeof o.project === "string" ? o.project : null;
|
|
345
|
+
const missionDomainsRaw = o.missionDomains;
|
|
346
|
+
const missionDomains = Array.isArray(missionDomainsRaw)
|
|
347
|
+
? missionDomainsRaw.filter((d) => typeof d === "string")
|
|
348
|
+
: [];
|
|
349
|
+
const agent = typeof o.agent === "string" ? o.agent : project !== null ? `cc-${project}` : "unknown";
|
|
350
|
+
const source = typeof o.source === "string" ? o.source : "(saved set)";
|
|
351
|
+
const sourceTag = SOURCE_TAGS.includes(String(o.sourceTag))
|
|
352
|
+
? o.sourceTag
|
|
353
|
+
: project !== null
|
|
354
|
+
? `cc-${project}` // best-effort backfill for a pre-v2 saved set
|
|
355
|
+
: `hermes-${agent}`;
|
|
356
|
+
return { prompt, agent, sourceTag, missionDomains, project, source };
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
function savePromptsJson(path, prompts) {
|
|
360
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true });
|
|
361
|
+
(0, node_fs_1.writeFileSync)(path, JSON.stringify(prompts, null, 2), "utf-8");
|
|
362
|
+
}
|
|
363
|
+
/** For each source (in SOURCE_TAGS order), the first SWEEP_N_PER_SOURCE
|
|
364
|
+
* prompts (by their position in the saved corpus) — the fixed 40-prompt
|
|
365
|
+
* length-sweep subset (spec §4.2). Deterministic given the saved order. */
|
|
366
|
+
function computeSweepSubsetIndices(prompts) {
|
|
367
|
+
const bySource = new Map();
|
|
368
|
+
prompts.forEach((p, idx) => {
|
|
369
|
+
const arr = bySource.get(p.sourceTag) ?? [];
|
|
370
|
+
arr.push(idx);
|
|
371
|
+
bySource.set(p.sourceTag, arr);
|
|
372
|
+
});
|
|
373
|
+
const out = [];
|
|
374
|
+
for (const tag of SOURCE_TAGS) {
|
|
375
|
+
out.push(...(bySource.get(tag) ?? []).slice(0, SWEEP_N_PER_SOURCE));
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
// Retrieve sweep (scope OFF vs ON) — LLM-free system-under-test
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
async function runRetrieveSweep(db, prompts) {
|
|
383
|
+
const embedCache = new Map();
|
|
384
|
+
for (const p of prompts) {
|
|
385
|
+
if (!embedCache.has(p.prompt))
|
|
386
|
+
embedCache.set(p.prompt, await (0, embedder_js_1.embed)(p.prompt));
|
|
387
|
+
}
|
|
388
|
+
const neverCalledEmbed = async () => {
|
|
389
|
+
throw new Error("relevance-eval: retrieve() called the embedFn — queryEmbedding was not honored. Eval aborted (results would be invalid).");
|
|
390
|
+
};
|
|
391
|
+
const results = [];
|
|
392
|
+
for (const sp of prompts) {
|
|
393
|
+
const queryEmbedding = embedCache.get(sp.prompt);
|
|
394
|
+
for (const mode of ["off", "on"]) {
|
|
395
|
+
const scopeOpts = mode === "on"
|
|
396
|
+
? sp.project !== null
|
|
397
|
+
? { project: sp.project }
|
|
398
|
+
: { missionDomains: sp.missionDomains }
|
|
399
|
+
: {};
|
|
400
|
+
const retrieved = await (0, retrieval_js_1.retrieve)(db, neverCalledEmbed, sp.prompt, {
|
|
401
|
+
limit: RESULT_K,
|
|
402
|
+
queryEmbedding,
|
|
403
|
+
noStrengthen: true,
|
|
404
|
+
...scopeOpts,
|
|
405
|
+
});
|
|
406
|
+
const topIds = retrieved.map((r) => r.id);
|
|
407
|
+
const contents = {};
|
|
408
|
+
const rows = {};
|
|
409
|
+
for (const r of retrieved) {
|
|
410
|
+
contents[r.id] = r.content;
|
|
411
|
+
rows[r.id] = r;
|
|
412
|
+
}
|
|
413
|
+
results.push({ prompt: sp, mode, topIds, contents, rows, fullVerdicts: [], lineVerdicts: [], redundancy: null });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return results;
|
|
417
|
+
}
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
// Rendering — production surface (imported, never reimplemented) + one
|
|
420
|
+
// exploratory variant (title + first sentence) that has no production
|
|
421
|
+
// equivalent, so it lives here.
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
const RECALL_BLOCK_HEADER = [
|
|
424
|
+
"## Memory recall (auto)",
|
|
425
|
+
// Copied verbatim from recall-index.ts's handleRecallIndex block header —
|
|
426
|
+
// DISPLAY TEXT only, not rendering LOGIC (the logic under measurement,
|
|
427
|
+
// formatIndexLine/memoryTitle, IS imported). Keep in sync if it changes.
|
|
428
|
+
"Possibly relevant memories — dates matter, newer supersedes older. Fetch with `hicortex_get(id)` when an entry could change your action. Cite what you rely on by id + date, and mark it `FETCHED` if you read the full memory or `SNIPPET` if you're citing the one-line entry unread — don't pass a SNIPPET citation off as established fact.",
|
|
429
|
+
];
|
|
430
|
+
/** Not a production surface — spec §4.2's exploratory 4th variant. Title
|
|
431
|
+
* (same memoryTitle() production helper) + one sentence of body, so the
|
|
432
|
+
* sweep can measure whether ONE extra sentence (not just a longer title)
|
|
433
|
+
* closes the snippet gap. Date formatting is a local dd.mm.yyyy helper (not
|
|
434
|
+
* exported from recall-index.ts) since this whole render path is synthetic. */
|
|
435
|
+
function ddmmyyyy(iso) {
|
|
436
|
+
const d = new Date(iso);
|
|
437
|
+
if (isNaN(d.getTime()))
|
|
438
|
+
return "";
|
|
439
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
440
|
+
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
441
|
+
return `${dd}.${mm}.${d.getFullYear()}`;
|
|
442
|
+
}
|
|
443
|
+
function firstSentence(text) {
|
|
444
|
+
const cleaned = text
|
|
445
|
+
.replace(/^#+\s*/, "")
|
|
446
|
+
.replace(/^Session Memory:\s*/i, "")
|
|
447
|
+
.replace(/^Lesson:\s*/i, "")
|
|
448
|
+
.trim();
|
|
449
|
+
const m = cleaned.match(/^(.*?[.!?])(\s|$)/);
|
|
450
|
+
return (m ? m[1] : cleaned).trim();
|
|
451
|
+
}
|
|
452
|
+
function renderTitleFirstSentenceLine(r) {
|
|
453
|
+
const title = (0, recall_index_js_1.memoryTitle)(r.content, 100);
|
|
454
|
+
const lines = r.content
|
|
455
|
+
.split("\n")
|
|
456
|
+
.map((l) => l.trim())
|
|
457
|
+
.filter((l) => l.length > 0);
|
|
458
|
+
const restAfterTitleLine = lines.slice(1).join(" ");
|
|
459
|
+
const sentence = firstSentence(restAfterTitleLine.length > 0 ? restAfterTitleLine : r.content);
|
|
460
|
+
const body = sentence && sentence.toLowerCase() !== title.toLowerCase() ? `${title} — ${sentence}` : title;
|
|
461
|
+
const meta = [ddmmyyyy(r.created_at), r.domain ?? r.project ?? undefined, r.source_agent ?? undefined, r.memory_type]
|
|
462
|
+
.filter(Boolean)
|
|
463
|
+
.join(", ");
|
|
464
|
+
return `- [${r.id}] ${body}${meta ? ` (${meta})` : ""}`;
|
|
465
|
+
}
|
|
466
|
+
function renderVariantLine(r, variant) {
|
|
467
|
+
if (variant === "title1sent")
|
|
468
|
+
return renderTitleFirstSentenceLine(r);
|
|
469
|
+
return (0, recall_index_js_1.formatIndexLine)(r, parseInt(variant, 10));
|
|
470
|
+
}
|
|
471
|
+
/** The ACTUAL production block: relevance-gated (passesRelevanceGate,
|
|
472
|
+
* imported), capped at maxItems, rendered via formatIndexLine (imported). */
|
|
473
|
+
function renderProductionBlock(rows, minSimilarity, maxItems, maxLen = PROD_TITLE_CHARS) {
|
|
474
|
+
const picked = rows.filter((r) => (0, recall_index_js_1.passesRelevanceGate)(r, minSimilarity)).slice(0, maxItems);
|
|
475
|
+
const lines = {};
|
|
476
|
+
for (const r of picked)
|
|
477
|
+
lines[r.id] = (0, recall_index_js_1.formatIndexLine)(r, maxLen);
|
|
478
|
+
const block = [...RECALL_BLOCK_HEADER, ...picked.map((r) => lines[r.id])].join("\n");
|
|
479
|
+
return { block, shownIds: picked.map((r) => r.id), lines };
|
|
480
|
+
}
|
|
481
|
+
/** char/4 token estimate — no tokenizer available locally; stated in the
|
|
482
|
+
* report. Good enough for a relative (variant-to-variant, K-to-K) comparison. */
|
|
483
|
+
function estimateTokens(s) {
|
|
484
|
+
return Math.ceil(s.length / 4);
|
|
485
|
+
}
|
|
486
|
+
// ---------------------------------------------------------------------------
|
|
487
|
+
// Judge prompts
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
function buildFullContentJudgePrompt(userPrompt, topIds, contents) {
|
|
490
|
+
const memBlock = [];
|
|
491
|
+
for (let i = 0; i < topIds.length; i++) {
|
|
492
|
+
const id = topIds[i];
|
|
493
|
+
const label = `Q${i + 1}`;
|
|
494
|
+
const full = contents[id] ?? "";
|
|
495
|
+
const trimmed = full.length > MAX_MEM_CHARS_IN_JUDGE ? full.slice(0, MAX_MEM_CHARS_IN_JUDGE) + " […truncated]" : full;
|
|
496
|
+
memBlock.push(`[${label}] (memory id ${id})\n${trimmed}`);
|
|
497
|
+
}
|
|
498
|
+
return ("You are a STRICT relevance judge for an AI coding agent's memory system. " +
|
|
499
|
+
"A real user prompt and the memories the retrieval system surfaced are " +
|
|
500
|
+
"below. Grade EACH memory independently against the SPECIFIC prompt.\n\n" +
|
|
501
|
+
"Verdicts (pick exactly one per memory):\n" +
|
|
502
|
+
"- relevant — genuinely useful for handling the user's actual need. The memory " +
|
|
503
|
+
"carries information, context, or a lesson that would help answer or " +
|
|
504
|
+
"contextualize the prompt. A tangential keyword overlap is NOT relevant.\n" +
|
|
505
|
+
"- noise — irrelevant. Off-topic, wrong scope, or surfaced purely on a " +
|
|
506
|
+
"keyword / lexical collision with no genuine connection to what the user is " +
|
|
507
|
+
"asking.\n" +
|
|
508
|
+
"- stale — once relevant but now outdated or superseded. The decision was " +
|
|
509
|
+
"reversed, the value moved, the API was removed, the lesson no longer holds.\n\n" +
|
|
510
|
+
"Grade relevance to the SPECIFIC prompt, not how interesting the memory is " +
|
|
511
|
+
"in general. When in doubt between 'relevant' and 'noise', prefer 'noise' — " +
|
|
512
|
+
"relevance inflation defeats the eval.\n\n" +
|
|
513
|
+
`USER PROMPT:\n${userPrompt}\n\n` +
|
|
514
|
+
`MEMORIES (Q1 = top-ranked):\n${memBlock.join("\n\n")}\n\n` +
|
|
515
|
+
"Return ONLY a JSON array — one entry per memory, in this exact shape, no " +
|
|
516
|
+
"prose, no markdown fences:\n" +
|
|
517
|
+
'[{"id": "Q1", "verdict": "relevant|noise|stale", "reason": "<= 12 words"}, ...]');
|
|
518
|
+
}
|
|
519
|
+
/** Spec §4.1 — the line judge is BLIND to full content: a separate call that
|
|
520
|
+
* sees ONLY the rendered production one-liner (or a sweep variant of it). */
|
|
521
|
+
function buildLineJudgePrompt(userPrompt, topIds, lines) {
|
|
522
|
+
const memBlock = [];
|
|
523
|
+
for (let i = 0; i < topIds.length; i++) {
|
|
524
|
+
const id = topIds[i];
|
|
525
|
+
memBlock.push(`[Q${i + 1}] ${lines[id] ?? ""}`);
|
|
526
|
+
}
|
|
527
|
+
return ("You are a STRICT relevance judge for an AI coding agent's memory system. " +
|
|
528
|
+
"The agent is shown ONLY the one-line index entry below per memory — " +
|
|
529
|
+
"NEVER the full content. Judge whether the AGENT COULD TELL, FROM THE LINE " +
|
|
530
|
+
"ALONE, that the memory is relevant to its actual need. Do not assume " +
|
|
531
|
+
"information that isn't visible in the line.\n\n" +
|
|
532
|
+
"Verdicts (pick exactly one per line):\n" +
|
|
533
|
+
"- relevant — the line itself signals genuine relevance to the prompt.\n" +
|
|
534
|
+
"- noise — the line signals no genuine connection, or is too vague/generic " +
|
|
535
|
+
"to tell anything from.\n" +
|
|
536
|
+
"- stale — the line itself signals outdated/superseded content.\n\n" +
|
|
537
|
+
"When in doubt, prefer 'noise' — relevance inflation defeats this measurement.\n\n" +
|
|
538
|
+
`USER PROMPT:\n${userPrompt}\n\n` +
|
|
539
|
+
`INDEX LINES (Q1 = top-ranked):\n${memBlock.join("\n")}\n\n` +
|
|
540
|
+
"Return ONLY a JSON array, same shape, no prose, no markdown fences:\n" +
|
|
541
|
+
'[{"id": "Q1", "verdict": "relevant|noise|stale", "reason": "<= 12 words"}, ...]');
|
|
542
|
+
}
|
|
543
|
+
function buildRedundancyPrompt(userPrompt, shownLines) {
|
|
544
|
+
const block = shownLines.map((l, i) => `[R${i + 1}] ${l}`).join("\n");
|
|
545
|
+
return ("You are grading REDUNDANCY in a set of memory index lines an AI agent " +
|
|
546
|
+
"would see TOGETHER for one prompt. Two lines are redundant if reading one " +
|
|
547
|
+
"makes the other add NOTHING NEW (near-duplicates, restatements, or a " +
|
|
548
|
+
"supersede-pair where only the newer one matters).\n\n" +
|
|
549
|
+
`USER PROMPT:\n${userPrompt}\n\n` +
|
|
550
|
+
`INDEX LINES SHOWN TOGETHER:\n${block}\n\n` +
|
|
551
|
+
`Return ONLY JSON, no prose, no markdown fences: {"non_redundant_count": <integer 0-${shownLines.length}>, "reason": "<= 15 words"}\n` +
|
|
552
|
+
"non_redundant_count = how many DISTINCT pieces of information are represented " +
|
|
553
|
+
"(total lines minus duplicates counted beyond the first in each redundant group).");
|
|
554
|
+
}
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
// Lenient JSON parse (v1, unchanged) — full/line/sweep share this parser.
|
|
557
|
+
// ---------------------------------------------------------------------------
|
|
558
|
+
const VALID_VERDICTS = new Set(["relevant", "noise", "stale"]);
|
|
559
|
+
function normalizeVerdict(v) {
|
|
560
|
+
if (typeof v !== "string")
|
|
561
|
+
return null;
|
|
562
|
+
const lower = v.toLowerCase().trim();
|
|
563
|
+
if (lower.startsWith("rel"))
|
|
564
|
+
return "relevant";
|
|
565
|
+
if (lower.startsWith("noise") || lower === "irrelevant")
|
|
566
|
+
return "noise";
|
|
567
|
+
if (lower.startsWith("stale") || lower.startsWith("outdated") || lower.startsWith("superse"))
|
|
568
|
+
return "stale";
|
|
569
|
+
return VALID_VERDICTS.has(lower) ? lower : null;
|
|
570
|
+
}
|
|
571
|
+
function parseJudgeOutput(raw, expectedLabels) {
|
|
572
|
+
const cleaned = raw.replace(/```(?:json)?/gi, "").trim();
|
|
573
|
+
const start = cleaned.indexOf("[");
|
|
574
|
+
const end = cleaned.lastIndexOf("]");
|
|
575
|
+
if (start >= 0 && end > start) {
|
|
576
|
+
try {
|
|
577
|
+
const parsed = JSON.parse(cleaned.slice(start, end + 1));
|
|
578
|
+
if (Array.isArray(parsed)) {
|
|
579
|
+
const out = [];
|
|
580
|
+
for (const item of parsed) {
|
|
581
|
+
if (typeof item !== "object" || item === null)
|
|
582
|
+
continue;
|
|
583
|
+
const id = String(item.id ?? "").toUpperCase();
|
|
584
|
+
if (!expectedLabels.has(id))
|
|
585
|
+
continue;
|
|
586
|
+
const verdict = normalizeVerdict(item.verdict);
|
|
587
|
+
if (!verdict)
|
|
588
|
+
continue;
|
|
589
|
+
const reason = String(item.reason ?? "").trim();
|
|
590
|
+
out.push({ id, verdict, reason });
|
|
591
|
+
}
|
|
592
|
+
if (out.length > 0)
|
|
593
|
+
return out;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
// fall through to heuristic
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const out = [];
|
|
601
|
+
for (const line of cleaned.split("\n")) {
|
|
602
|
+
const m = line.match(/\b(Q\d+)\b[\s\-:)]*([A-Za-z]+)/);
|
|
603
|
+
if (!m)
|
|
604
|
+
continue;
|
|
605
|
+
const id = m[1].toUpperCase();
|
|
606
|
+
if (!expectedLabels.has(id))
|
|
607
|
+
continue;
|
|
608
|
+
const verdict = normalizeVerdict(m[2]);
|
|
609
|
+
if (!verdict)
|
|
610
|
+
continue;
|
|
611
|
+
const tail = line.slice((m.index ?? 0) + m[0].length).replace(/^[\s\-:;.,)]+/, "");
|
|
612
|
+
const reason = tail.split(/[.\n]/)[0].trim().slice(0, 80);
|
|
613
|
+
out.push({ id, verdict, reason });
|
|
614
|
+
}
|
|
615
|
+
return out.length > 0 ? out : null;
|
|
616
|
+
}
|
|
617
|
+
function parseRedundancyOutput(raw, shown) {
|
|
618
|
+
const cleaned = raw.replace(/```(?:json)?/gi, "").trim();
|
|
619
|
+
const start = cleaned.indexOf("{");
|
|
620
|
+
const end = cleaned.lastIndexOf("}");
|
|
621
|
+
if (start >= 0 && end > start) {
|
|
622
|
+
try {
|
|
623
|
+
const obj = JSON.parse(cleaned.slice(start, end + 1));
|
|
624
|
+
const n = Number(obj.non_redundant_count);
|
|
625
|
+
if (Number.isFinite(n)) {
|
|
626
|
+
return {
|
|
627
|
+
nonRedundantCount: Math.max(0, Math.min(shown, Math.round(n))),
|
|
628
|
+
reason: String(obj.reason ?? "").trim(),
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
// fall through
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
const m = cleaned.match(/(\d+)/);
|
|
637
|
+
if (m)
|
|
638
|
+
return { nonRedundantCount: Math.max(0, Math.min(shown, parseInt(m[1], 10))), reason: "" };
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
// ---------------------------------------------------------------------------
|
|
642
|
+
// Rate limiting + resumability (spec §6b, MANDATORY)
|
|
643
|
+
// ---------------------------------------------------------------------------
|
|
644
|
+
class JudgeHttpError extends Error {
|
|
645
|
+
status;
|
|
646
|
+
retryAfterMs;
|
|
647
|
+
constructor(status, message, retryAfterMs) {
|
|
648
|
+
super(message);
|
|
649
|
+
this.name = "JudgeHttpError";
|
|
650
|
+
this.status = status;
|
|
651
|
+
this.retryAfterMs = retryAfterMs;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
/** Thrown when the run must stop (budget or error-rate abort). Caught once at
|
|
655
|
+
* the top of the judging pipeline in main() — whatever completed so far is
|
|
656
|
+
* still persisted and reported, clearly marked as an aborted/partial run. */
|
|
657
|
+
class RunAbortedError extends Error {
|
|
658
|
+
}
|
|
659
|
+
function sleep(ms) {
|
|
660
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
661
|
+
}
|
|
662
|
+
/** ONE raw HTTP attempt against z.ai's Anthropic-compatible endpoint. Mirrors
|
|
663
|
+
* llm.ts#completeAnthropic's request shape exactly (model/messages/max_tokens,
|
|
664
|
+
* x-api-key header) but with NO built-in retry — the backoff wrapper below
|
|
665
|
+
* owns retries so the schedule matches spec §6b precisely. */
|
|
666
|
+
async function callZaiOnce(apiKey, prompt, maxTokens, timeoutMs = 90_000) {
|
|
667
|
+
const url = `${ZAI_BASE_URL.replace(/\/$/, "")}/v1/messages`;
|
|
668
|
+
let resp;
|
|
669
|
+
try {
|
|
670
|
+
resp = await fetch(url, {
|
|
671
|
+
method: "POST",
|
|
672
|
+
headers: {
|
|
673
|
+
"Content-Type": "application/json",
|
|
674
|
+
"x-api-key": apiKey,
|
|
675
|
+
"anthropic-version": "2023-06-01",
|
|
676
|
+
},
|
|
677
|
+
body: JSON.stringify({ model: ZAI_MODEL, messages: [{ role: "user", content: prompt }], max_tokens: maxTokens }),
|
|
678
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
catch (err) {
|
|
682
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
683
|
+
throw new JudgeHttpError(0, `network/timeout: ${msg}`); // status 0 = retryable (network/timeout)
|
|
684
|
+
}
|
|
685
|
+
if (resp.status === 429 || resp.status >= 500) {
|
|
686
|
+
const retryAfterHeader = resp.headers.get("retry-after");
|
|
687
|
+
const retryAfterMs = retryAfterHeader ? parseInt(retryAfterHeader, 10) * 1000 : undefined;
|
|
688
|
+
const text = await resp.text().catch(() => "");
|
|
689
|
+
throw new JudgeHttpError(resp.status, `HTTP ${resp.status}: ${text.slice(0, 200)}`, retryAfterMs);
|
|
690
|
+
}
|
|
691
|
+
if (!resp.ok) {
|
|
692
|
+
const text = await resp.text().catch(() => "");
|
|
693
|
+
throw new JudgeHttpError(resp.status, `HTTP ${resp.status} (non-retryable): ${text.slice(0, 200)}`);
|
|
694
|
+
}
|
|
695
|
+
const data = (await resp.json());
|
|
696
|
+
const block = data.content?.find((c) => c.type === "text");
|
|
697
|
+
return (block?.text ?? "").trim();
|
|
698
|
+
}
|
|
699
|
+
function jitter(ms) {
|
|
700
|
+
return Math.round(ms * (0.8 + Math.random() * 0.4)); // ±20%
|
|
701
|
+
}
|
|
702
|
+
/** Retryable = 429, 5xx, or network/timeout (status 0). Any other non-ok
|
|
703
|
+
* status (400/401/403/404) fails immediately — retrying a malformed request
|
|
704
|
+
* or an auth failure would waste the call budget on a guaranteed repeat. */
|
|
705
|
+
function isRetryable(err) {
|
|
706
|
+
return err instanceof JudgeHttpError && (err.status === 429 || err.status >= 500 || err.status === 0);
|
|
707
|
+
}
|
|
708
|
+
async function callZaiWithBackoff(prompt, maxTokens, state) {
|
|
709
|
+
let lastErr;
|
|
710
|
+
for (let attempt = 0; attempt <= BACKOFF_SCHEDULE_MS.length; attempt++) {
|
|
711
|
+
try {
|
|
712
|
+
return await callZaiOnce(state.apiKey, prompt, maxTokens);
|
|
713
|
+
}
|
|
714
|
+
catch (err) {
|
|
715
|
+
lastErr = err;
|
|
716
|
+
if (!isRetryable(err) || attempt === BACKOFF_SCHEDULE_MS.length)
|
|
717
|
+
throw err;
|
|
718
|
+
const scheduled = BACKOFF_SCHEDULE_MS[attempt];
|
|
719
|
+
const waitMs = err.retryAfterMs ?? jitter(scheduled); // Retry-After wins over the schedule.
|
|
720
|
+
state.totalRetries++;
|
|
721
|
+
console.log(`[relevance-eval] judge call failed (${err.message.slice(0, 100)}), ` +
|
|
722
|
+
`retry ${attempt + 1}/${BACKOFF_SCHEDULE_MS.length} in ${Math.round(waitMs / 1000)}s...`);
|
|
723
|
+
await sleep(waitMs);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
throw lastErr;
|
|
727
|
+
}
|
|
728
|
+
function loadResumeMap(sidecarPath) {
|
|
729
|
+
const map = new Map();
|
|
730
|
+
if (!(0, node_fs_1.existsSync)(sidecarPath))
|
|
731
|
+
return map;
|
|
732
|
+
const raw = (0, node_fs_1.readFileSync)(sidecarPath, "utf-8");
|
|
733
|
+
for (const line of raw.split("\n")) {
|
|
734
|
+
if (!line.trim())
|
|
735
|
+
continue;
|
|
736
|
+
try {
|
|
737
|
+
const obj = JSON.parse(line);
|
|
738
|
+
if (obj && typeof obj.key === "string")
|
|
739
|
+
map.set(obj.key, obj.result);
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
// corrupt checkpoint line — skip, don't crash the whole resume.
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return map;
|
|
746
|
+
}
|
|
747
|
+
/** One judge UNIT: resume-cache check → budget check → delay → call w/
|
|
748
|
+
* backoff → parse → checkpoint-append → progress log → error-rate check. */
|
|
749
|
+
async function runJudgeUnit(state, key, buildPrompt, maxTokens, parse, parseErrorLabel) {
|
|
750
|
+
const cached = state.resumed.get(key);
|
|
751
|
+
if (cached !== undefined) {
|
|
752
|
+
console.log(`[relevance-eval] resumed: ${key}`);
|
|
753
|
+
return cached;
|
|
754
|
+
}
|
|
755
|
+
if (state.aborted)
|
|
756
|
+
throw new RunAbortedError(state.aborted.reason);
|
|
757
|
+
if (state.totalCalls >= state.maxCalls) {
|
|
758
|
+
state.aborted = { reason: `--max-calls budget (${state.maxCalls}) reached` };
|
|
759
|
+
throw new RunAbortedError(state.aborted.reason);
|
|
760
|
+
}
|
|
761
|
+
await sleep(state.judgeDelayMs);
|
|
762
|
+
state.totalCalls++;
|
|
763
|
+
let result;
|
|
764
|
+
try {
|
|
765
|
+
const raw = await callZaiWithBackoff(buildPrompt(), maxTokens, state);
|
|
766
|
+
const parsed = parse(raw);
|
|
767
|
+
result = parsed ?? { judgeError: `${parseErrorLabel}; raw head: ${raw.slice(0, 200).replace(/\s+/g, " ")}` };
|
|
768
|
+
}
|
|
769
|
+
catch (err) {
|
|
770
|
+
result = { judgeError: (err instanceof Error ? err.message : String(err)).slice(0, 300) };
|
|
771
|
+
}
|
|
772
|
+
state.completedUnits++;
|
|
773
|
+
if (typeof result === "object" && result !== null && "judgeError" in result) {
|
|
774
|
+
state.errorUnits++;
|
|
775
|
+
}
|
|
776
|
+
(0, node_fs_1.appendFileSync)(state.sidecarPath, JSON.stringify({ key, result }) + "\n", "utf-8");
|
|
777
|
+
const elapsedS = Math.round((Date.now() - state.startTime) / 1000);
|
|
778
|
+
console.log(`[relevance-eval] judged ${state.completedUnits} (calls ${state.totalCalls}/${state.maxCalls}, ` +
|
|
779
|
+
`${elapsedS}s elapsed, ${state.totalRetries} retries, ${state.errorUnits} judge_error) — ${key}`);
|
|
780
|
+
if (state.completedUnits >= ERROR_RATE_MIN_SAMPLE && state.errorUnits / state.completedUnits > ERROR_RATE_ABORT_THRESHOLD) {
|
|
781
|
+
state.aborted = {
|
|
782
|
+
reason: `judge-error rate ${((state.errorUnits / state.completedUnits) * 100).toFixed(1)}% ` +
|
|
783
|
+
`exceeds the 10% abort threshold after ${state.completedUnits} units`,
|
|
784
|
+
};
|
|
785
|
+
throw new RunAbortedError(state.aborted.reason);
|
|
786
|
+
}
|
|
787
|
+
return result;
|
|
788
|
+
}
|
|
789
|
+
async function judgeVerdictUnit(state, key, promptText, expectedLabels) {
|
|
790
|
+
if (expectedLabels.size === 0)
|
|
791
|
+
return { judgeError: "retrieve() surfaced 0 memories — nothing to grade" };
|
|
792
|
+
return runJudgeUnit(state, key, () => promptText, 2048, (raw) => parseJudgeOutput(raw, expectedLabels), "parse failed");
|
|
793
|
+
}
|
|
794
|
+
async function judgeRedundancyUnit(state, key, promptText, shown) {
|
|
795
|
+
return runJudgeUnit(state, key, () => promptText, 512, (raw) => parseRedundancyOutput(raw, shown), "parse failed");
|
|
796
|
+
}
|
|
797
|
+
function computeMetricsAtK(results, k) {
|
|
798
|
+
const judged = results.filter((r) => Array.isArray(r.fullVerdicts));
|
|
799
|
+
if (judged.length === 0)
|
|
800
|
+
return null;
|
|
801
|
+
const inK = (id) => {
|
|
802
|
+
const idx = parseInt(id.slice(1), 10);
|
|
803
|
+
return Number.isInteger(idx) && idx >= 1 && idx <= k;
|
|
804
|
+
};
|
|
805
|
+
let relTotal = 0, noiseTotal = 0, staleTotal = 0, verdictTotal = 0;
|
|
806
|
+
const perPrompt = [];
|
|
807
|
+
for (const r of judged) {
|
|
808
|
+
let promptRel = 0;
|
|
809
|
+
for (const v of r.fullVerdicts) {
|
|
810
|
+
if (!inK(v.id))
|
|
811
|
+
continue;
|
|
812
|
+
verdictTotal++;
|
|
813
|
+
if (v.verdict === "relevant") {
|
|
814
|
+
relTotal++;
|
|
815
|
+
promptRel++;
|
|
816
|
+
}
|
|
817
|
+
else if (v.verdict === "noise")
|
|
818
|
+
noiseTotal++;
|
|
819
|
+
else
|
|
820
|
+
staleTotal++;
|
|
821
|
+
}
|
|
822
|
+
perPrompt.push(promptRel / k);
|
|
823
|
+
}
|
|
824
|
+
return {
|
|
825
|
+
precision: perPrompt.reduce((a, b) => a + b, 0) / perPrompt.length,
|
|
826
|
+
noiseRate: verdictTotal > 0 ? noiseTotal / verdictTotal : 0,
|
|
827
|
+
staleRate: verdictTotal > 0 ? staleTotal / verdictTotal : 0,
|
|
828
|
+
n: judged.length,
|
|
829
|
+
perPrompt,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
function computePromptHistogram(results, mode) {
|
|
833
|
+
const buckets = new Array(RESULT_K + 1).fill(0);
|
|
834
|
+
for (const r of results.filter((r) => r.mode === mode)) {
|
|
835
|
+
if (!Array.isArray(r.fullVerdicts))
|
|
836
|
+
continue;
|
|
837
|
+
const rel = r.fullVerdicts.filter((v) => v.verdict === "relevant").length;
|
|
838
|
+
if (rel >= 0 && rel <= RESULT_K)
|
|
839
|
+
buckets[rel]++;
|
|
840
|
+
}
|
|
841
|
+
return buckets;
|
|
842
|
+
}
|
|
843
|
+
function findWorstBestPrompts(results, mode, worstRelMax, bestRelMin) {
|
|
844
|
+
const named = [];
|
|
845
|
+
for (const r of results.filter((r) => r.mode === mode)) {
|
|
846
|
+
if (!Array.isArray(r.fullVerdicts))
|
|
847
|
+
continue;
|
|
848
|
+
let rel = 0, noise = 0, stale = 0;
|
|
849
|
+
const perRank = [];
|
|
850
|
+
for (const v of r.fullVerdicts) {
|
|
851
|
+
const rank = parseInt(v.id.slice(1), 10);
|
|
852
|
+
const memId = r.topIds[rank - 1] ?? "?";
|
|
853
|
+
if (v.verdict === "relevant")
|
|
854
|
+
rel++;
|
|
855
|
+
else if (v.verdict === "noise")
|
|
856
|
+
noise++;
|
|
857
|
+
else
|
|
858
|
+
stale++;
|
|
859
|
+
perRank.push({ rank, memory_id: memId, verdict: v.verdict, reason: v.reason, content: r.contents[memId] ?? "" });
|
|
860
|
+
}
|
|
861
|
+
named.push({ prompt: r.prompt.prompt, agent: r.prompt.agent, relevant: rel, noise, stale, perRank });
|
|
862
|
+
}
|
|
863
|
+
return {
|
|
864
|
+
worst: named.filter((p) => p.relevant <= worstRelMax),
|
|
865
|
+
best: named.filter((p) => p.relevant >= bestRelMin),
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
function rankChronicMemories(results, metaById) {
|
|
869
|
+
const byId = new Map();
|
|
870
|
+
for (const r of results) {
|
|
871
|
+
if (!Array.isArray(r.fullVerdicts))
|
|
872
|
+
continue;
|
|
873
|
+
const seenNoiseForPrompt = new Set();
|
|
874
|
+
const seenStaleForPrompt = new Set();
|
|
875
|
+
const seenSurfaceForPrompt = new Set();
|
|
876
|
+
for (const v of r.fullVerdicts) {
|
|
877
|
+
const rank = parseInt(v.id.slice(1), 10);
|
|
878
|
+
const memId = r.topIds[rank - 1];
|
|
879
|
+
if (!memId)
|
|
880
|
+
continue;
|
|
881
|
+
let entry = byId.get(memId);
|
|
882
|
+
if (!entry) {
|
|
883
|
+
const meta = metaById.get(memId) ?? { content: r.contents[memId] ?? "", memory_type: null, project: null };
|
|
884
|
+
entry = { memory_id: memId, noisePrompts: 0, stalePrompts: 0, totalSurfaces: 0, ...meta };
|
|
885
|
+
byId.set(memId, entry);
|
|
886
|
+
}
|
|
887
|
+
if (!seenSurfaceForPrompt.has(memId)) {
|
|
888
|
+
entry.totalSurfaces++;
|
|
889
|
+
seenSurfaceForPrompt.add(memId);
|
|
890
|
+
}
|
|
891
|
+
if (v.verdict === "noise" && !seenNoiseForPrompt.has(memId)) {
|
|
892
|
+
entry.noisePrompts++;
|
|
893
|
+
seenNoiseForPrompt.add(memId);
|
|
894
|
+
}
|
|
895
|
+
else if (v.verdict === "stale" && !seenStaleForPrompt.has(memId)) {
|
|
896
|
+
entry.stalePrompts++;
|
|
897
|
+
seenStaleForPrompt.add(memId);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
return Array.from(byId.values());
|
|
902
|
+
}
|
|
903
|
+
function lookupMemoryMeta(db, results) {
|
|
904
|
+
const ids = new Set();
|
|
905
|
+
for (const r of results)
|
|
906
|
+
for (const id of r.topIds)
|
|
907
|
+
ids.add(id);
|
|
908
|
+
const out = new Map();
|
|
909
|
+
if (ids.size === 0)
|
|
910
|
+
return out;
|
|
911
|
+
const placeholders = Array.from(ids, () => "?").join(",");
|
|
912
|
+
try {
|
|
913
|
+
const rows = db
|
|
914
|
+
.prepare(`SELECT id, content, memory_type, project FROM memories WHERE id IN (${placeholders})`)
|
|
915
|
+
.all(...ids);
|
|
916
|
+
for (const row of rows) {
|
|
917
|
+
out.set(row.id, { content: row.content ?? "", memory_type: row.memory_type, project: row.project });
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
catch {
|
|
921
|
+
// Schema drift — empty map; callers fall back to cached content.
|
|
922
|
+
}
|
|
923
|
+
for (const r of results) {
|
|
924
|
+
for (const id of r.topIds) {
|
|
925
|
+
if (!out.has(id))
|
|
926
|
+
out.set(id, { content: r.contents[id] ?? "", memory_type: null, project: null });
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
return out;
|
|
930
|
+
}
|
|
931
|
+
function computeRankMetrics(results) {
|
|
932
|
+
const judged = results.filter((r) => Array.isArray(r.fullVerdicts));
|
|
933
|
+
const rel = new Array(RESULT_K).fill(0);
|
|
934
|
+
const noise = new Array(RESULT_K).fill(0);
|
|
935
|
+
const stale = new Array(RESULT_K).fill(0);
|
|
936
|
+
const n = new Array(RESULT_K).fill(0);
|
|
937
|
+
for (const r of judged) {
|
|
938
|
+
for (const v of r.fullVerdicts) {
|
|
939
|
+
const idx = parseInt(v.id.slice(1), 10) - 1;
|
|
940
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= RESULT_K)
|
|
941
|
+
continue;
|
|
942
|
+
n[idx]++;
|
|
943
|
+
if (v.verdict === "relevant")
|
|
944
|
+
rel[idx]++;
|
|
945
|
+
else if (v.verdict === "noise")
|
|
946
|
+
noise[idx]++;
|
|
947
|
+
else
|
|
948
|
+
stale[idx]++;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
const rows = [];
|
|
952
|
+
for (let i = 0; i < RESULT_K; i++) {
|
|
953
|
+
rows.push({
|
|
954
|
+
rank: i + 1,
|
|
955
|
+
n: n[i],
|
|
956
|
+
relevantRate: n[i] > 0 ? rel[i] / n[i] : 0,
|
|
957
|
+
noiseRate: n[i] > 0 ? noise[i] / n[i] : 0,
|
|
958
|
+
staleRate: n[i] > 0 ? stale[i] / n[i] : 0,
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
return rows;
|
|
962
|
+
}
|
|
963
|
+
function aggregateRankBand(results, fromRank, toRank) {
|
|
964
|
+
const judged = results.filter((r) => Array.isArray(r.fullVerdicts));
|
|
965
|
+
const want = new Set();
|
|
966
|
+
for (let r = fromRank; r <= toRank; r++)
|
|
967
|
+
want.add(`Q${r}`);
|
|
968
|
+
let noise = 0, total = 0;
|
|
969
|
+
for (const r of judged) {
|
|
970
|
+
for (const v of r.fullVerdicts) {
|
|
971
|
+
if (!want.has(v.id))
|
|
972
|
+
continue;
|
|
973
|
+
total++;
|
|
974
|
+
if (v.verdict === "noise")
|
|
975
|
+
noise++;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
return { noise, total, rate: total > 0 ? noise / total : 0 };
|
|
979
|
+
}
|
|
980
|
+
function computeSnippetGap(results) {
|
|
981
|
+
let actionable = 0, snippetFailure = 0, misleadingLine = 0, fullRelevantTotal = 0, n = 0;
|
|
982
|
+
for (const r of results) {
|
|
983
|
+
if (!Array.isArray(r.fullVerdicts) || !Array.isArray(r.lineVerdicts))
|
|
984
|
+
continue;
|
|
985
|
+
const lineById = new Map(r.lineVerdicts.map((v) => [v.id, v]));
|
|
986
|
+
for (const fv of r.fullVerdicts) {
|
|
987
|
+
const lv = lineById.get(fv.id);
|
|
988
|
+
if (!lv)
|
|
989
|
+
continue;
|
|
990
|
+
n++;
|
|
991
|
+
const fullRel = fv.verdict === "relevant";
|
|
992
|
+
const lineRel = lv.verdict === "relevant";
|
|
993
|
+
if (fullRel)
|
|
994
|
+
fullRelevantTotal++;
|
|
995
|
+
if (fullRel && lineRel)
|
|
996
|
+
actionable++;
|
|
997
|
+
else if (fullRel && !lineRel)
|
|
998
|
+
snippetFailure++;
|
|
999
|
+
else if (!fullRel && lineRel)
|
|
1000
|
+
misleadingLine++;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
return { actionable, snippetFailure, misleadingLine, fullRelevantTotal, n };
|
|
1004
|
+
}
|
|
1005
|
+
function findNamedSnippetFailures(results, limit) {
|
|
1006
|
+
const out = [];
|
|
1007
|
+
for (const r of results) {
|
|
1008
|
+
if (!Array.isArray(r.fullVerdicts) || !Array.isArray(r.lineVerdicts))
|
|
1009
|
+
continue;
|
|
1010
|
+
const lineById = new Map(r.lineVerdicts.map((v) => [v.id, v]));
|
|
1011
|
+
for (const fv of r.fullVerdicts) {
|
|
1012
|
+
const lv = lineById.get(fv.id);
|
|
1013
|
+
if (!lv)
|
|
1014
|
+
continue;
|
|
1015
|
+
if (fv.verdict === "relevant" && lv.verdict !== "relevant") {
|
|
1016
|
+
const rank = parseInt(fv.id.slice(1), 10);
|
|
1017
|
+
const memId = r.topIds[rank - 1] ?? "?";
|
|
1018
|
+
const row = r.rows[memId];
|
|
1019
|
+
out.push({
|
|
1020
|
+
prompt: r.prompt.prompt,
|
|
1021
|
+
agent: r.prompt.agent,
|
|
1022
|
+
mode: r.mode,
|
|
1023
|
+
memory_id: memId,
|
|
1024
|
+
rendered_line: row ? (0, recall_index_js_1.formatIndexLine)(row, PROD_TITLE_CHARS) : "(unavailable)",
|
|
1025
|
+
full_reason: fv.reason,
|
|
1026
|
+
line_reason: lv.reason,
|
|
1027
|
+
content: r.contents[memId] ?? "",
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
return out.slice(0, limit);
|
|
1033
|
+
}
|
|
1034
|
+
function mean(xs) {
|
|
1035
|
+
return xs.length === 0 ? NaN : xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
1036
|
+
}
|
|
1037
|
+
function sd(xs) {
|
|
1038
|
+
if (xs.length < 2)
|
|
1039
|
+
return 0;
|
|
1040
|
+
const m = mean(xs);
|
|
1041
|
+
return Math.sqrt(xs.reduce((a, b) => a + (b - m) ** 2, 0) / (xs.length - 1));
|
|
1042
|
+
}
|
|
1043
|
+
function ci95(xs) {
|
|
1044
|
+
const m = mean(xs);
|
|
1045
|
+
const s = sd(xs);
|
|
1046
|
+
const se = xs.length > 0 ? s / Math.sqrt(xs.length) : NaN;
|
|
1047
|
+
return { mean: m, sd: s, se, lo: m - 1.96 * se, hi: m + 1.96 * se, n: xs.length };
|
|
1048
|
+
}
|
|
1049
|
+
/** Similarity buckets for §5.9 — width-0.05 bands from 0.30 to 1.00, plus a
|
|
1050
|
+
* catch-all "<0.30" and a "n/a" bucket for FTS/graph hits (no measured
|
|
1051
|
+
* cosine — they bypass the floor entirely per passesRelevanceGate). */
|
|
1052
|
+
const SIM_BUCKET_EDGES = [0.3, 0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 1.001];
|
|
1053
|
+
function similarityBucketLabel(sim) {
|
|
1054
|
+
if (sim === null || sim === undefined)
|
|
1055
|
+
return "n/a (fts/graph, no cosine)";
|
|
1056
|
+
if (sim < SIM_BUCKET_EDGES[0])
|
|
1057
|
+
return "<0.30";
|
|
1058
|
+
for (let i = 0; i < SIM_BUCKET_EDGES.length - 1; i++) {
|
|
1059
|
+
if (sim >= SIM_BUCKET_EDGES[i] && sim < SIM_BUCKET_EDGES[i + 1]) {
|
|
1060
|
+
const hi = Math.min(SIM_BUCKET_EDGES[i + 1], 1);
|
|
1061
|
+
return `${SIM_BUCKET_EDGES[i].toFixed(2)}–${hi.toFixed(2)}`;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
return ">=1.00";
|
|
1065
|
+
}
|
|
1066
|
+
function buildVerdictRows(prompts, results, sweepRows) {
|
|
1067
|
+
const byKey = new Map();
|
|
1068
|
+
for (const r of results) {
|
|
1069
|
+
const pIdx = prompts.findIndex((p) => p.prompt === r.prompt.prompt && p.agent === r.prompt.agent);
|
|
1070
|
+
byKey.set(`${pIdx}:${r.mode}`, r);
|
|
1071
|
+
}
|
|
1072
|
+
const rows = [];
|
|
1073
|
+
// Baseline (variant PROD_TITLE_CHARS, production render) — one row per (prompt, mode, rank).
|
|
1074
|
+
for (let i = 0; i < prompts.length; i++) {
|
|
1075
|
+
for (const mode of ["off", "on"]) {
|
|
1076
|
+
const r = byKey.get(`${i}:${mode}`);
|
|
1077
|
+
if (!r)
|
|
1078
|
+
continue;
|
|
1079
|
+
const fullOk = Array.isArray(r.fullVerdicts);
|
|
1080
|
+
const lineOk = Array.isArray(r.lineVerdicts);
|
|
1081
|
+
const fullById = fullOk ? new Map(r.fullVerdicts.map((v) => [v.id, v])) : null;
|
|
1082
|
+
const lineById = lineOk ? new Map(r.lineVerdicts.map((v) => [v.id, v])) : null;
|
|
1083
|
+
const ranks = r.topIds.length > 0 ? r.topIds.map((_, idx) => idx + 1) : [0];
|
|
1084
|
+
for (const rank of ranks) {
|
|
1085
|
+
const label = `Q${rank}`;
|
|
1086
|
+
const memId = rank > 0 ? r.topIds[rank - 1] : "";
|
|
1087
|
+
const row = memId ? r.rows[memId] : undefined;
|
|
1088
|
+
const fv = fullById?.get(label);
|
|
1089
|
+
const lv = lineById?.get(label);
|
|
1090
|
+
rows.push({
|
|
1091
|
+
prompt_idx: i,
|
|
1092
|
+
prompt: r.prompt.prompt,
|
|
1093
|
+
source: r.prompt.sourceTag,
|
|
1094
|
+
agent: r.prompt.agent,
|
|
1095
|
+
mode,
|
|
1096
|
+
rank,
|
|
1097
|
+
memory_id: memId,
|
|
1098
|
+
memory_type: row?.memory_type ?? null,
|
|
1099
|
+
similarity: row?.similarity ?? null,
|
|
1100
|
+
retrieval_source: row?.source ?? null,
|
|
1101
|
+
full_verdict: fv ? fv.verdict : fullOk ? null : "judge_error",
|
|
1102
|
+
full_reason: fv ? fv.reason : fullOk ? "" : r.fullVerdicts.judgeError,
|
|
1103
|
+
line_verdict: lv ? lv.verdict : lineOk ? null : "judge_error",
|
|
1104
|
+
line_reason: lv ? lv.reason : lineOk ? "" : r.lineVerdicts.judgeError,
|
|
1105
|
+
rendered_line: row ? (0, recall_index_js_1.formatIndexLine)(row, PROD_TITLE_CHARS) : "",
|
|
1106
|
+
snippet_len_variant: String(PROD_TITLE_CHARS),
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
// Sweep rows (variants 100/120/200/title1sent — PROD_TITLE_CHARS (150)
|
|
1112
|
+
// already covered above by the baseline pass; mode is always "off" per the
|
|
1113
|
+
// resource-bounded design, spec §4.2).
|
|
1114
|
+
for (const sw of sweepRows) {
|
|
1115
|
+
if (sw.variant === String(PROD_TITLE_CHARS))
|
|
1116
|
+
continue; // covered by the baseline pass above.
|
|
1117
|
+
const r = byKey.get(`${sw.promptIdx}:off`);
|
|
1118
|
+
if (!r)
|
|
1119
|
+
continue;
|
|
1120
|
+
const fullById = Array.isArray(r.fullVerdicts) ? new Map(r.fullVerdicts.map((v) => [v.id, v])) : null;
|
|
1121
|
+
const lineOk = Array.isArray(sw.verdicts);
|
|
1122
|
+
const lineById = lineOk ? new Map(sw.verdicts.map((v) => [v.id, v])) : null;
|
|
1123
|
+
for (let rank = 1; rank <= r.topIds.length; rank++) {
|
|
1124
|
+
const label = `Q${rank}`;
|
|
1125
|
+
const memId = r.topIds[rank - 1];
|
|
1126
|
+
const row = r.rows[memId];
|
|
1127
|
+
const fv = fullById?.get(label);
|
|
1128
|
+
const lv = lineById?.get(label);
|
|
1129
|
+
rows.push({
|
|
1130
|
+
prompt_idx: sw.promptIdx,
|
|
1131
|
+
prompt: r.prompt.prompt,
|
|
1132
|
+
source: r.prompt.sourceTag,
|
|
1133
|
+
agent: r.prompt.agent,
|
|
1134
|
+
mode: "off",
|
|
1135
|
+
rank,
|
|
1136
|
+
memory_id: memId,
|
|
1137
|
+
memory_type: row?.memory_type ?? null,
|
|
1138
|
+
similarity: row?.similarity ?? null,
|
|
1139
|
+
retrieval_source: row?.source ?? null,
|
|
1140
|
+
full_verdict: fv ? fv.verdict : null, // carried over from the baseline full judging; not re-run per variant.
|
|
1141
|
+
full_reason: fv ? fv.reason : "",
|
|
1142
|
+
line_verdict: lv ? lv.verdict : lineOk ? null : "judge_error",
|
|
1143
|
+
line_reason: lv ? lv.reason : lineOk ? "" : sw.verdicts.judgeError,
|
|
1144
|
+
rendered_line: sw.lines[memId] ?? "",
|
|
1145
|
+
snippet_len_variant: sw.variant,
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
return rows;
|
|
1150
|
+
}
|
|
1151
|
+
function parseArgs(argv) {
|
|
1152
|
+
const positionals = [];
|
|
1153
|
+
let judgeDelayMs = DEFAULT_JUDGE_DELAY_MS;
|
|
1154
|
+
let maxCalls = DEFAULT_MAX_CALLS;
|
|
1155
|
+
let resume = false;
|
|
1156
|
+
let verdictsJsonPath = VERDICTS_JSON_PATH;
|
|
1157
|
+
let verdictsJsonlPath = VERDICTS_JSONL_PATH;
|
|
1158
|
+
for (const arg of argv) {
|
|
1159
|
+
if (arg === "--resume") {
|
|
1160
|
+
resume = true;
|
|
1161
|
+
continue;
|
|
1162
|
+
}
|
|
1163
|
+
const m = arg.match(/^--([a-z-]+)=(.+)$/);
|
|
1164
|
+
if (m) {
|
|
1165
|
+
if (m[1] === "judge-delay-ms")
|
|
1166
|
+
judgeDelayMs = parseInt(m[2], 10);
|
|
1167
|
+
else if (m[1] === "max-calls")
|
|
1168
|
+
maxCalls = parseInt(m[2], 10);
|
|
1169
|
+
else if (m[1] === "verdicts-json")
|
|
1170
|
+
verdictsJsonPath = m[2];
|
|
1171
|
+
else if (m[1] === "verdicts-jsonl")
|
|
1172
|
+
verdictsJsonlPath = m[2];
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
positionals.push(arg);
|
|
1176
|
+
}
|
|
1177
|
+
return { positionals, judgeDelayMs, maxCalls, resume, verdictsJsonPath, verdictsJsonlPath };
|
|
1178
|
+
}
|
|
1179
|
+
// ---------------------------------------------------------------------------
|
|
1180
|
+
// Reporting
|
|
1181
|
+
// ---------------------------------------------------------------------------
|
|
1182
|
+
function pct(x) {
|
|
1183
|
+
return Number.isFinite(x) ? `${(x * 100).toFixed(1)}%` : "n/a";
|
|
1184
|
+
}
|
|
1185
|
+
function pts(x) {
|
|
1186
|
+
return Number.isFinite(x) ? `${(x * 100).toFixed(1)}pts` : "n/a";
|
|
1187
|
+
}
|
|
1188
|
+
function fmtNum(x, decimals = 2) {
|
|
1189
|
+
return Number.isFinite(x) ? x.toFixed(decimals) : "n/a";
|
|
1190
|
+
}
|
|
1191
|
+
function head(s, n) {
|
|
1192
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
1193
|
+
return flat.length > n ? flat.slice(0, n) + "…" : flat;
|
|
1194
|
+
}
|
|
1195
|
+
function renderReport(args) {
|
|
1196
|
+
const { prompts, results, sweepRows, sweepSubsetIdx, verdictRows, judgeState } = args;
|
|
1197
|
+
const off = results.filter((r) => r.mode === "off");
|
|
1198
|
+
const on = results.filter((r) => r.mode === "on");
|
|
1199
|
+
const L = [];
|
|
1200
|
+
if (judgeState.aborted) {
|
|
1201
|
+
L.push(`# ⚠ ABORTED RUN — ${judgeState.aborted.reason}\n\n` +
|
|
1202
|
+
`_The report below reflects only what completed before the abort. ${judgeState.completedUnits} judge ` +
|
|
1203
|
+
`units completed, ${judgeState.errorUnits} judge_error, ${judgeState.totalCalls} fresh calls this ` +
|
|
1204
|
+
`invocation. Re-run with \`--resume\` after addressing the cause._\n`);
|
|
1205
|
+
}
|
|
1206
|
+
L.push("# Recall Relevance + Snippet Eval — v2\n");
|
|
1207
|
+
L.push(`Snapshot: \`${args.snapshotPath}\` \n` +
|
|
1208
|
+
`Prompts: \`${args.promptsPath}\` (${prompts.length} prompts; requested ${SOURCE_TAGS.length * N_PER_SOURCE} — ` +
|
|
1209
|
+
`see §0 for any per-source shortfall) \n` +
|
|
1210
|
+
`Generated: ${args.generatedAt}\n`);
|
|
1211
|
+
L.push(`_Spec: \`specs/2026-08-02-relevance-eval.md\`. Real agent prompts replayed through \`retrieve()\` on a ` +
|
|
1212
|
+
`READONLY snapshot; GLM-5.2 (z.ai) is the JUDGE only — retrieve() is LLM-free. Two independent, BLIND judge ` +
|
|
1213
|
+
`calls per (prompt × mode): \`full_verdict\` (up to 2000 chars) and \`line_verdict\` (ONLY the rendered ` +
|
|
1214
|
+
`production one-liner, imported from \`recall-index.ts#formatIndexLine\`/\`memoryTitle\` — never ` +
|
|
1215
|
+
`reimplemented). Static DB (\`noStrengthen: true\`); real bge-small-en-v1.5 embedder; embed-once via ` +
|
|
1216
|
+
`\`queryEmbedding\` with a \`neverCalledEmbed\` self-check. Retrieve K=${RESULT_K}; precision@4/@${PROD_MENU_K}/@${RESULT_K} ` +
|
|
1217
|
+
`all derived from the SAME verdicts. Token counts are a char/4 ESTIMATE (no tokenizer available locally — stated ` +
|
|
1218
|
+
`explicitly wherever used). Raw per-verdict rows saved to \`${args.verdictsJsonPath}\` for ad-hoc tuning ` +
|
|
1219
|
+
`analysis without re-judging._\n`);
|
|
1220
|
+
// =====================================================================
|
|
1221
|
+
// §0 — corpus construction (source counts, sweep subset)
|
|
1222
|
+
// =====================================================================
|
|
1223
|
+
L.push("## 0. Corpus\n");
|
|
1224
|
+
const bySource = new Map();
|
|
1225
|
+
for (const p of prompts)
|
|
1226
|
+
bySource.set(p.sourceTag, (bySource.get(p.sourceTag) ?? 0) + 1);
|
|
1227
|
+
L.push("| source | prompts | of requested |");
|
|
1228
|
+
L.push("|---|---|---|");
|
|
1229
|
+
for (const tag of SOURCE_TAGS) {
|
|
1230
|
+
L.push(`| ${tag} | ${bySource.get(tag) ?? 0} | ${N_PER_SOURCE} |`);
|
|
1231
|
+
}
|
|
1232
|
+
L.push("");
|
|
1233
|
+
L.push(`Length-sweep fixed subset (spec §4.2): ${sweepSubsetIdx.length} prompts (${SWEEP_N_PER_SOURCE}/source), ` +
|
|
1234
|
+
`scope OFF only (the true baseline).\n`);
|
|
1235
|
+
// =====================================================================
|
|
1236
|
+
// §1 — per-prompt precision histogram (v1, unchanged shape)
|
|
1237
|
+
// =====================================================================
|
|
1238
|
+
L.push(`## 1. Per-prompt precision histogram (relevant-count in Q1-Q${RESULT_K}, full_verdict)\n`);
|
|
1239
|
+
const offHist = computePromptHistogram(results, "off");
|
|
1240
|
+
const onHist = computePromptHistogram(results, "on");
|
|
1241
|
+
L.push("| relevant-count | OFF prompt-count | ON prompt-count |");
|
|
1242
|
+
L.push("|---|---|---|");
|
|
1243
|
+
for (let i = 0; i <= RESULT_K; i++) {
|
|
1244
|
+
const marker = i <= 1 ? " ← worst" : i >= RESULT_K - 1 ? " ← best" : "";
|
|
1245
|
+
L.push(`| ${i}${marker} | ${offHist[i]} | ${onHist[i]} |`);
|
|
1246
|
+
}
|
|
1247
|
+
L.push("");
|
|
1248
|
+
// =====================================================================
|
|
1249
|
+
// §2 — worst/best named prompts
|
|
1250
|
+
// =====================================================================
|
|
1251
|
+
L.push(`## 2. Worst (0-1 relevant) and best (${RESULT_K - 1}-${RESULT_K} relevant) prompts — scope OFF\n`);
|
|
1252
|
+
const offWb = findWorstBestPrompts(results, "off", 1, RESULT_K - 1);
|
|
1253
|
+
L.push(`### Worst — ${offWb.worst.length} prompt(s)\n`);
|
|
1254
|
+
if (offWb.worst.length === 0) {
|
|
1255
|
+
L.push("_(none)_\n");
|
|
1256
|
+
}
|
|
1257
|
+
else {
|
|
1258
|
+
for (const p of offWb.worst.slice(0, 6)) {
|
|
1259
|
+
L.push(`- **[${p.agent}]** \`${head(p.prompt, 160)}\``);
|
|
1260
|
+
L.push(` - tally: ${p.relevant} relevant / ${p.noise} noise / ${p.stale} stale`);
|
|
1261
|
+
for (const v of p.perRank) {
|
|
1262
|
+
L.push(` - Q${v.rank} \`${v.memory_id.slice(0, 8)}\` **${v.verdict}** — ${v.reason || "_(no reason)_"} — \`${head(v.content, 100)}\``);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
L.push("");
|
|
1266
|
+
}
|
|
1267
|
+
L.push(`### Best — ${offWb.best.length} prompt(s)\n`);
|
|
1268
|
+
if (offWb.best.length === 0) {
|
|
1269
|
+
L.push("_(none)_\n");
|
|
1270
|
+
}
|
|
1271
|
+
else {
|
|
1272
|
+
for (const p of offWb.best.slice(0, 4)) {
|
|
1273
|
+
L.push(`- **[${p.agent}]** \`${head(p.prompt, 160)}\` — ${p.relevant} relevant / ${p.noise} noise / ${p.stale} stale`);
|
|
1274
|
+
}
|
|
1275
|
+
L.push("");
|
|
1276
|
+
}
|
|
1277
|
+
// =====================================================================
|
|
1278
|
+
// §3/§4 — chronic-noise / chronic-stale
|
|
1279
|
+
// =====================================================================
|
|
1280
|
+
const metaById = lookupMemoryMeta(args.db, results);
|
|
1281
|
+
const chronic = rankChronicMemories(results, metaById);
|
|
1282
|
+
const chronicNoise = [...chronic].sort((a, b) => b.noisePrompts - a.noisePrompts).filter((c) => c.noisePrompts > 0);
|
|
1283
|
+
const chronicStale = [...chronic].sort((a, b) => b.stalePrompts - a.stalePrompts).filter((c) => c.stalePrompts > 0);
|
|
1284
|
+
L.push(`## 3. Chronic-noise memories (ranked pollution sources) — HEADLINE\n`);
|
|
1285
|
+
if (chronicNoise.length === 0) {
|
|
1286
|
+
L.push("_(none)_\n");
|
|
1287
|
+
}
|
|
1288
|
+
else {
|
|
1289
|
+
L.push("| # | noise-prompts | stale-prompts | surfaces | type | project | id | content snippet |");
|
|
1290
|
+
L.push("|---|---|---|---|---|---|---|---|");
|
|
1291
|
+
for (let i = 0; i < Math.min(15, chronicNoise.length); i++) {
|
|
1292
|
+
const c = chronicNoise[i];
|
|
1293
|
+
L.push(`| ${i + 1} | ${c.noisePrompts} | ${c.stalePrompts} | ${c.totalSurfaces} | ${c.memory_type ?? "-"} | ` +
|
|
1294
|
+
`${c.project ?? "-"} | \`${c.memory_id.slice(0, 8)}\` | \`${head(c.content, 100)}\` |`);
|
|
1295
|
+
}
|
|
1296
|
+
L.push("");
|
|
1297
|
+
}
|
|
1298
|
+
L.push(`## 4. Chronic-stale memories (supersession gaps)\n`);
|
|
1299
|
+
if (chronicStale.length === 0) {
|
|
1300
|
+
L.push("_(none)_\n");
|
|
1301
|
+
}
|
|
1302
|
+
else {
|
|
1303
|
+
L.push("| # | stale-prompts | noise-prompts | surfaces | type | project | id | content snippet |");
|
|
1304
|
+
L.push("|---|---|---|---|---|---|---|---|");
|
|
1305
|
+
for (let i = 0; i < Math.min(15, chronicStale.length); i++) {
|
|
1306
|
+
const c = chronicStale[i];
|
|
1307
|
+
L.push(`| ${i + 1} | ${c.stalePrompts} | ${c.noisePrompts} | ${c.totalSurfaces} | ${c.memory_type ?? "-"} | ` +
|
|
1308
|
+
`${c.project ?? "-"} | \`${c.memory_id.slice(0, 8)}\` | \`${head(c.content, 100)}\` |`);
|
|
1309
|
+
}
|
|
1310
|
+
L.push("");
|
|
1311
|
+
}
|
|
1312
|
+
// =====================================================================
|
|
1313
|
+
// §5 — K-sweep + per-rank + top/bottom + bubble (v1, supporting)
|
|
1314
|
+
// =====================================================================
|
|
1315
|
+
L.push(`## 5. K-sweep (supporting — precision@4 / @${PROD_MENU_K} / @${RESULT_K}, full_verdict)\n`);
|
|
1316
|
+
L.push("| mode | precision@4 | precision@6 | precision@8 | noise@4 | noise@6 | noise@8 | stale@6 |");
|
|
1317
|
+
L.push("|---|---|---|---|---|---|---|---|");
|
|
1318
|
+
const m4off = computeMetricsAtK(off, 4), m6off = computeMetricsAtK(off, 6), m8off = computeMetricsAtK(off, 8);
|
|
1319
|
+
const m4on = computeMetricsAtK(on, 4), m6on = computeMetricsAtK(on, 6), m8on = computeMetricsAtK(on, 8);
|
|
1320
|
+
if (m4off && m6off && m8off) {
|
|
1321
|
+
L.push(`| OFF | ${pct(m4off.precision)} | ${pct(m6off.precision)} | ${pct(m8off.precision)} | ${pct(m4off.noiseRate)} | ${pct(m6off.noiseRate)} | ${pct(m8off.noiseRate)} | ${pct(m6off.staleRate)} |`);
|
|
1322
|
+
}
|
|
1323
|
+
if (m4on && m6on && m8on) {
|
|
1324
|
+
L.push(`| ON | ${pct(m4on.precision)} | ${pct(m6on.precision)} | ${pct(m8on.precision)} | ${pct(m4on.noiseRate)} | ${pct(m6on.noiseRate)} | ${pct(m8on.noiseRate)} | ${pct(m6on.staleRate)} |`);
|
|
1325
|
+
}
|
|
1326
|
+
L.push("");
|
|
1327
|
+
L.push(`## 6. Per-rank verdict distribution (Q1..Q${RESULT_K}, full_verdict)\n`);
|
|
1328
|
+
for (const [label, rs] of [["OFF", off], ["ON", on]]) {
|
|
1329
|
+
const rows = computeRankMetrics(rs);
|
|
1330
|
+
L.push(`### Scope ${label}\n`);
|
|
1331
|
+
L.push("| rank | band | N | relevant | noise | stale |");
|
|
1332
|
+
L.push("|---|---|---|---|---|---|");
|
|
1333
|
+
for (const r of rows) {
|
|
1334
|
+
const band = r.rank <= PROD_MENU_K ? "menu" : "bubble";
|
|
1335
|
+
L.push(`| Q${r.rank} | ${band} | ${r.n} | ${Math.round(r.relevantRate * r.n)} | ${Math.round(r.noiseRate * r.n)} | ${Math.round(r.staleRate * r.n)} |`);
|
|
1336
|
+
}
|
|
1337
|
+
L.push("");
|
|
1338
|
+
}
|
|
1339
|
+
const offTop = aggregateRankBand(off, 1, 3);
|
|
1340
|
+
const offBot = aggregateRankBand(off, 4, PROD_MENU_K);
|
|
1341
|
+
const onTop = aggregateRankBand(on, 1, 3);
|
|
1342
|
+
const onBot = aggregateRankBand(on, 4, PROD_MENU_K);
|
|
1343
|
+
L.push(`## 7. Top-3 vs bottom-3 noise split + bubble (supporting)\n`);
|
|
1344
|
+
L.push("| mode | band | noise / N | noise-rate |");
|
|
1345
|
+
L.push("|---|---|---|---|");
|
|
1346
|
+
L.push(`| OFF | top-3 | ${offTop.noise}/${offTop.total} | **${pct(offTop.rate)}** |`);
|
|
1347
|
+
L.push(`| OFF | bottom-3 | ${offBot.noise}/${offBot.total} | ${pct(offBot.rate)} |`);
|
|
1348
|
+
L.push(`| ON | top-3 | ${onTop.noise}/${onTop.total} | **${pct(onTop.rate)}** |`);
|
|
1349
|
+
L.push(`| ON | bottom-3 | ${onBot.noise}/${onBot.total} | ${pct(onBot.rate)} |`);
|
|
1350
|
+
L.push("");
|
|
1351
|
+
const rateFromRows = (rows) => {
|
|
1352
|
+
const rel = rows.reduce((s, r) => s + Math.round(r.relevantRate * r.n), 0);
|
|
1353
|
+
const total = rows.reduce((s, r) => s + r.n, 0);
|
|
1354
|
+
return { rel, total, rate: total > 0 ? rel / total : 0 };
|
|
1355
|
+
};
|
|
1356
|
+
const offMenuRel = rateFromRows(computeRankMetrics(off).slice(0, PROD_MENU_K));
|
|
1357
|
+
const onMenuRel = rateFromRows(computeRankMetrics(on).slice(0, PROD_MENU_K));
|
|
1358
|
+
const offBubRel = rateFromRows(computeRankMetrics(off).slice(PROD_MENU_K));
|
|
1359
|
+
const onBubRel = rateFromRows(computeRankMetrics(on).slice(PROD_MENU_K));
|
|
1360
|
+
L.push(`Bubble (Q${PROD_MENU_K + 1}-Q${RESULT_K}) relevant-rate vs the menu.\n`);
|
|
1361
|
+
L.push("| mode | band | relevant / N | relevant-rate |");
|
|
1362
|
+
L.push("|---|---|---|---|");
|
|
1363
|
+
L.push(`| OFF | menu | ${offMenuRel.rel}/${offMenuRel.total} | ${pct(offMenuRel.rate)} |`);
|
|
1364
|
+
L.push(`| OFF | bubble | ${offBubRel.rel}/${offBubRel.total} | **${pct(offBubRel.rate)}** |`);
|
|
1365
|
+
L.push(`| ON | menu | ${onMenuRel.rel}/${onMenuRel.total} | ${pct(onMenuRel.rate)} |`);
|
|
1366
|
+
L.push(`| ON | bubble | ${onBubRel.rel}/${onBubRel.total} | **${pct(onBubRel.rate)}** |`);
|
|
1367
|
+
L.push("");
|
|
1368
|
+
// =====================================================================
|
|
1369
|
+
// §8 — per-source breakdown (spec §5.7)
|
|
1370
|
+
// =====================================================================
|
|
1371
|
+
L.push("## 8. Per-source breakdown (scope OFF, precision@6)\n");
|
|
1372
|
+
L.push("| source | precision@6 | noise@6 | stale@6 | n prompts |");
|
|
1373
|
+
L.push("|---|---|---|---|---|");
|
|
1374
|
+
for (const tag of SOURCE_TAGS) {
|
|
1375
|
+
const rs = off.filter((r) => r.prompt.sourceTag === tag);
|
|
1376
|
+
const m = computeMetricsAtK(rs, PROD_MENU_K);
|
|
1377
|
+
L.push(`| ${tag} | ${m ? pct(m.precision) : "n/a"} | ${m ? pct(m.noiseRate) : "n/a"} | ${m ? pct(m.staleRate) : "n/a"} | ${m ? m.n : 0} |`);
|
|
1378
|
+
}
|
|
1379
|
+
L.push("");
|
|
1380
|
+
// =====================================================================
|
|
1381
|
+
// §9 — snippet layer: dual verdict + named failures (spec §4.1, §5.8) — HEADLINE
|
|
1382
|
+
// =====================================================================
|
|
1383
|
+
L.push("## 9. Snippet layer — dual verdict (full_verdict vs line_verdict) — HEADLINE\n");
|
|
1384
|
+
const gapOff = computeSnippetGap(off);
|
|
1385
|
+
const gapOn = computeSnippetGap(on);
|
|
1386
|
+
const gapAll = computeSnippetGap(results);
|
|
1387
|
+
const snippetFailureRate = (g) => (g.fullRelevantTotal > 0 ? g.snippetFailure / g.fullRelevantTotal : NaN);
|
|
1388
|
+
L.push("| scope | full-relevant (denominator) | actionable | snippet_failure | snippet_failure_rate | misleading_line |");
|
|
1389
|
+
L.push("|---|---|---|---|---|---|");
|
|
1390
|
+
L.push(`| OFF | ${gapOff.fullRelevantTotal} | ${gapOff.actionable} | ${gapOff.snippetFailure} | **${pct(snippetFailureRate(gapOff))}** | ${gapOff.misleadingLine} |`);
|
|
1391
|
+
L.push(`| ON | ${gapOn.fullRelevantTotal} | ${gapOn.actionable} | ${gapOn.snippetFailure} | **${pct(snippetFailureRate(gapOn))}** | ${gapOn.misleadingLine} |`);
|
|
1392
|
+
L.push(`| BOTH | ${gapAll.fullRelevantTotal} | ${gapAll.actionable} | ${gapAll.snippetFailure} | **${pct(snippetFailureRate(gapAll))}** | ${gapAll.misleadingLine} |`);
|
|
1393
|
+
L.push("");
|
|
1394
|
+
L.push(`_snippet_failure_rate = snippet_failure / full-relevant — "of the memories retrieve() got RIGHT, what fraction ` +
|
|
1395
|
+
`does the ~100-char production line fail to communicate as relevant?" misleading_line = the agent is BAITED ` +
|
|
1396
|
+
`by a line that reads relevant for a memory that (on full inspection) is not._\n`);
|
|
1397
|
+
const namedFailures = findNamedSnippetFailures(results, 15);
|
|
1398
|
+
L.push(`### Named worst snippet failures (up to 15)\n`);
|
|
1399
|
+
if (namedFailures.length === 0) {
|
|
1400
|
+
L.push("_(none — every full-relevant memory's line also read as relevant)_\n");
|
|
1401
|
+
}
|
|
1402
|
+
else {
|
|
1403
|
+
for (const f of namedFailures) {
|
|
1404
|
+
L.push(`- **[${f.mode}/${f.agent}]** prompt: \`${head(f.prompt, 120)}\``);
|
|
1405
|
+
L.push(` - rendered line: \`${f.rendered_line}\``);
|
|
1406
|
+
L.push(` - full_verdict=relevant (${f.full_reason || "no reason"}) but line_verdict≠relevant (${f.line_reason || "no reason"})`);
|
|
1407
|
+
L.push(` - full content: \`${head(f.content, 140)}\``);
|
|
1408
|
+
}
|
|
1409
|
+
L.push("");
|
|
1410
|
+
}
|
|
1411
|
+
// =====================================================================
|
|
1412
|
+
// §10 — snippet-length sweep (spec §4.2, §5.8) — HEADLINE
|
|
1413
|
+
// =====================================================================
|
|
1414
|
+
L.push(`## 10. Snippet-length sweep (N=${sweepSubsetIdx.length}, scope OFF, Q1-Q${RESULT_K}) — HEADLINE\n`);
|
|
1415
|
+
L.push(`_Same prompts, same memories — only the rendering changes per variant. CI is ~1.6× wider than the N=100 tables ` +
|
|
1416
|
+
`(spec §8); only effects ≥ ~15pts are distinguishable here. ${PROD_TITLE_CHARS} is the PRODUCTION baseline ` +
|
|
1417
|
+
`(shipped \`recallTitleChars\`) — deltas are reported against it, not against 100._\n`);
|
|
1418
|
+
L.push(`| variant | line-relevant-rate | 95% CI | Δ vs ${PROD_TITLE_CHARS} (prod) | mean tokens/block (prod-6, char/4) |`);
|
|
1419
|
+
L.push("|---|---|---|---|---|");
|
|
1420
|
+
const sweepByVariant = new Map();
|
|
1421
|
+
for (const v of SWEEP_VARIANTS)
|
|
1422
|
+
sweepByVariant.set(v, sweepRows.filter((s) => s.variant === v));
|
|
1423
|
+
const sweepVariantCi = new Map();
|
|
1424
|
+
const sweepVariantTokens = new Map();
|
|
1425
|
+
// Pass 1: compute CI + token estimate per variant (order-independent — the
|
|
1426
|
+
// baseline lookup below must not depend on iterating variants in a
|
|
1427
|
+
// particular order, since PROD_TITLE_CHARS (150) is no longer first in
|
|
1428
|
+
// SWEEP_VARIANTS' display order).
|
|
1429
|
+
for (const variant of SWEEP_VARIANTS) {
|
|
1430
|
+
const rowsForVariant = sweepByVariant.get(variant) ?? [];
|
|
1431
|
+
const perPrompt = [];
|
|
1432
|
+
const tokenSamples = [];
|
|
1433
|
+
for (const sw of rowsForVariant) {
|
|
1434
|
+
if (!Array.isArray(sw.verdicts))
|
|
1435
|
+
continue;
|
|
1436
|
+
const relCount = sw.verdicts.filter((v) => v.verdict === "relevant").length;
|
|
1437
|
+
const denom = sw.verdicts.length || 1;
|
|
1438
|
+
perPrompt.push(relCount / denom);
|
|
1439
|
+
const off6 = off.find((r) => prompts.indexOf(r.prompt) === sw.promptIdx);
|
|
1440
|
+
if (off6) {
|
|
1441
|
+
const rowsArr = off6.topIds.map((id) => off6.rows[id]).filter(Boolean);
|
|
1442
|
+
const gated = rowsArr.filter((r) => (0, recall_index_js_1.passesRelevanceGate)(r, PROD_MIN_SIMILARITY)).slice(0, PROD_MENU_K);
|
|
1443
|
+
const block = [...RECALL_BLOCK_HEADER, ...gated.map((r) => sw.lines[r.id] ?? "")].join("\n");
|
|
1444
|
+
tokenSamples.push(estimateTokens(block));
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
sweepVariantCi.set(variant, ci95(perPrompt));
|
|
1448
|
+
sweepVariantTokens.set(variant, tokenSamples.length ? mean(tokenSamples) : NaN);
|
|
1449
|
+
}
|
|
1450
|
+
// Pass 2: render rows in SWEEP_VARIANTS order, delta always vs the
|
|
1451
|
+
// production baseline (PROD_TITLE_CHARS), looked up by key not by position.
|
|
1452
|
+
const baselineCi = sweepVariantCi.get(String(PROD_TITLE_CHARS)) ?? null;
|
|
1453
|
+
for (const variant of SWEEP_VARIANTS) {
|
|
1454
|
+
const c = sweepVariantCi.get(variant);
|
|
1455
|
+
const tokenSamples = sweepVariantTokens.get(variant);
|
|
1456
|
+
const isBaseline = variant === String(PROD_TITLE_CHARS);
|
|
1457
|
+
const delta = baselineCi && !isBaseline ? c.mean - baselineCi.mean : null;
|
|
1458
|
+
const variantLabel = isBaseline ? `${variant} (prod)` : variant;
|
|
1459
|
+
L.push(`| ${variantLabel} | ${pct(c.mean)} | [${pct(c.lo)}, ${pct(c.hi)}] | ${delta === null ? "—" : pts(delta)} | ${Number.isFinite(tokenSamples) ? tokenSamples.toFixed(0) : "n/a"} |`);
|
|
1460
|
+
}
|
|
1461
|
+
L.push("");
|
|
1462
|
+
// =====================================================================
|
|
1463
|
+
// §11 — similarity-floor + retrieval-source analysis (spec §5.9)
|
|
1464
|
+
// =====================================================================
|
|
1465
|
+
L.push(`## 11. Similarity-floor + retrieval-source analysis (full_verdict, variant=${PROD_TITLE_CHARS} baseline only)\n`);
|
|
1466
|
+
const baseRows = verdictRows.filter((r) => r.snippet_len_variant === String(PROD_TITLE_CHARS) && r.full_verdict !== null);
|
|
1467
|
+
const byBucket = new Map();
|
|
1468
|
+
for (const r of baseRows) {
|
|
1469
|
+
if (r.full_verdict === "judge_error")
|
|
1470
|
+
continue;
|
|
1471
|
+
const label = similarityBucketLabel(r.similarity);
|
|
1472
|
+
const e = byBucket.get(label) ?? { relevant: 0, noise: 0, stale: 0, total: 0 };
|
|
1473
|
+
e.total++;
|
|
1474
|
+
if (r.full_verdict === "relevant")
|
|
1475
|
+
e.relevant++;
|
|
1476
|
+
else if (r.full_verdict === "noise")
|
|
1477
|
+
e.noise++;
|
|
1478
|
+
else if (r.full_verdict === "stale")
|
|
1479
|
+
e.stale++;
|
|
1480
|
+
byBucket.set(label, e);
|
|
1481
|
+
}
|
|
1482
|
+
L.push("### By similarity band\n");
|
|
1483
|
+
L.push("| band | n | relevant | noise | stale | noise-rate |");
|
|
1484
|
+
L.push("|---|---|---|---|---|---|");
|
|
1485
|
+
const bucketOrder = ["n/a (fts/graph, no cosine)", "<0.30", ...SIM_BUCKET_EDGES.slice(0, -1).map((_, i) => `${SIM_BUCKET_EDGES[i].toFixed(2)}–${Math.min(SIM_BUCKET_EDGES[i + 1], 1).toFixed(2)}`)];
|
|
1486
|
+
for (const label of bucketOrder) {
|
|
1487
|
+
const e = byBucket.get(label);
|
|
1488
|
+
if (!e)
|
|
1489
|
+
continue;
|
|
1490
|
+
L.push(`| ${label} | ${e.total} | ${e.relevant} | ${e.noise} | ${e.stale} | ${pct(e.total > 0 ? e.noise / e.total : 0)} |`);
|
|
1491
|
+
}
|
|
1492
|
+
L.push("");
|
|
1493
|
+
L.push("### By retrieval source (channel)\n");
|
|
1494
|
+
const bySrc = new Map();
|
|
1495
|
+
for (const r of baseRows) {
|
|
1496
|
+
if (r.full_verdict === "judge_error")
|
|
1497
|
+
continue;
|
|
1498
|
+
const label = r.retrieval_source ?? "(unknown)";
|
|
1499
|
+
const e = bySrc.get(label) ?? { relevant: 0, noise: 0, stale: 0, total: 0 };
|
|
1500
|
+
e.total++;
|
|
1501
|
+
if (r.full_verdict === "relevant")
|
|
1502
|
+
e.relevant++;
|
|
1503
|
+
else if (r.full_verdict === "noise")
|
|
1504
|
+
e.noise++;
|
|
1505
|
+
else if (r.full_verdict === "stale")
|
|
1506
|
+
e.stale++;
|
|
1507
|
+
bySrc.set(label, e);
|
|
1508
|
+
}
|
|
1509
|
+
L.push("| retrieval_source | n | relevant | noise | stale | noise-rate |");
|
|
1510
|
+
L.push("|---|---|---|---|---|---|");
|
|
1511
|
+
for (const [label, e] of Array.from(bySrc.entries()).sort((a, b) => b[1].total - a[1].total)) {
|
|
1512
|
+
L.push(`| ${label} | ${e.total} | ${e.relevant} | ${e.noise} | ${e.stale} | ${pct(e.total > 0 ? e.noise / e.total : 0)} |`);
|
|
1513
|
+
}
|
|
1514
|
+
L.push("");
|
|
1515
|
+
// =====================================================================
|
|
1516
|
+
// §12 — token cost per K (spec §5.10)
|
|
1517
|
+
// =====================================================================
|
|
1518
|
+
L.push(`## 12. Token cost per K (production render, maxLen=${PROD_TITLE_CHARS}, scope OFF, char/4 estimate)\n`);
|
|
1519
|
+
L.push("| K | mean tokens/block | mean chars/block |");
|
|
1520
|
+
L.push("|---|---|---|");
|
|
1521
|
+
for (const k of SWEEP_KS) {
|
|
1522
|
+
const tokenSamples = [];
|
|
1523
|
+
for (const r of off) {
|
|
1524
|
+
const rowsArr = r.topIds.map((id) => r.rows[id]).filter(Boolean);
|
|
1525
|
+
const { block } = renderProductionBlock(rowsArr, PROD_MIN_SIMILARITY, k, PROD_TITLE_CHARS);
|
|
1526
|
+
tokenSamples.push(estimateTokens(block));
|
|
1527
|
+
}
|
|
1528
|
+
L.push(`| ${k} | ${fmtNum(mean(tokenSamples), 0)} | ${fmtNum(mean(tokenSamples) * 4, 0)} |`);
|
|
1529
|
+
}
|
|
1530
|
+
L.push("\n_Per-variant token cost is in §10's sweep table (same char/4 estimate, prod-6-gated block)._\n");
|
|
1531
|
+
// =====================================================================
|
|
1532
|
+
// §13 — rendered production blocks (spec §5.11) — ~20 mixed best/worst
|
|
1533
|
+
// =====================================================================
|
|
1534
|
+
L.push(`## 13. Rendered ACTUAL production blocks (mixed best/worst, scope OFF, maxLen=${PROD_TITLE_CHARS})\n`);
|
|
1535
|
+
L.push(`_Exactly what \`/recall-index\` would render: \`passesRelevanceGate\` (minSimilarity=${PROD_MIN_SIMILARITY}) then ` +
|
|
1536
|
+
`capped at ${PROD_MENU_K}. NOTE: candidate pool here is Q1-Q${RESULT_K} (this eval's replay window); production ` +
|
|
1537
|
+
`over-fetches \`maxItems×3\` candidates before gating, so a real production block could differ slightly on a ` +
|
|
1538
|
+
`sparse query — a known, documented simplification, not a bug._\n`);
|
|
1539
|
+
const worstBlocks = offWb.worst.slice(0, 10);
|
|
1540
|
+
const bestBlocks = offWb.best.slice(0, 10);
|
|
1541
|
+
let shown = 0;
|
|
1542
|
+
for (const label of ["worst", "best"]) {
|
|
1543
|
+
const set = label === "worst" ? worstBlocks : bestBlocks;
|
|
1544
|
+
for (const p of set) {
|
|
1545
|
+
if (shown >= 20)
|
|
1546
|
+
break;
|
|
1547
|
+
const r = off.find((x) => x.prompt.prompt === p.prompt && x.prompt.agent === p.agent);
|
|
1548
|
+
if (!r)
|
|
1549
|
+
continue;
|
|
1550
|
+
const rowsArr = r.topIds.map((id) => r.rows[id]).filter(Boolean);
|
|
1551
|
+
const { block } = renderProductionBlock(rowsArr, PROD_MIN_SIMILARITY, PROD_MENU_K, PROD_TITLE_CHARS);
|
|
1552
|
+
L.push(`### [${label}] ${p.agent}: \`${head(p.prompt, 100)}\`\n`);
|
|
1553
|
+
L.push("```");
|
|
1554
|
+
L.push(block || "(null — no candidate passed the relevance gate)");
|
|
1555
|
+
L.push("```\n");
|
|
1556
|
+
shown++;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
// =====================================================================
|
|
1560
|
+
// §14 — redundancy (spec §5.12)
|
|
1561
|
+
// =====================================================================
|
|
1562
|
+
L.push("## 14. Redundancy over the production 6 (set-level judge call per prompt × mode)\n");
|
|
1563
|
+
const redundancySamples = [];
|
|
1564
|
+
for (const r of results) {
|
|
1565
|
+
if (!r.redundancy)
|
|
1566
|
+
continue;
|
|
1567
|
+
if ("skipped" in r.redundancy)
|
|
1568
|
+
redundancySamples.push({ mode: r.mode, shown: r.redundancy.shown, nonRedundant: r.redundancy.shown });
|
|
1569
|
+
else if (!("judgeError" in r.redundancy))
|
|
1570
|
+
redundancySamples.push({ mode: r.mode, shown: r.redundancy.shown, nonRedundant: r.redundancy.nonRedundantCount });
|
|
1571
|
+
}
|
|
1572
|
+
L.push("| mode | n prompts | mean shown | mean non-redundant | mean redundant slots |");
|
|
1573
|
+
L.push("|---|---|---|---|---|");
|
|
1574
|
+
for (const label of ["off", "on"]) {
|
|
1575
|
+
const rs = redundancySamples.filter((s) => s.mode === label);
|
|
1576
|
+
const meanShown = mean(rs.map((s) => s.shown));
|
|
1577
|
+
const meanNr = mean(rs.map((s) => s.nonRedundant));
|
|
1578
|
+
L.push(`| ${label.toUpperCase()} | ${rs.length} | ${fmtNum(meanShown)} | ${fmtNum(meanNr)} | ${fmtNum(meanShown - meanNr)} |`);
|
|
1579
|
+
}
|
|
1580
|
+
L.push("");
|
|
1581
|
+
// =====================================================================
|
|
1582
|
+
// §15 — uncertainty (spec §5.13, §8)
|
|
1583
|
+
// =====================================================================
|
|
1584
|
+
L.push("## 15. Uncertainty — SD / SE / 95% CI\n");
|
|
1585
|
+
const p6off = computeMetricsAtK(off, PROD_MENU_K);
|
|
1586
|
+
const p6on = computeMetricsAtK(on, PROD_MENU_K);
|
|
1587
|
+
L.push("| metric | mode | mean | SD | SE | 95% CI |");
|
|
1588
|
+
L.push("|---|---|---|---|---|---|");
|
|
1589
|
+
if (p6off) {
|
|
1590
|
+
const c = ci95(p6off.perPrompt);
|
|
1591
|
+
L.push(`| precision@${PROD_MENU_K} | OFF | ${pct(c.mean)} | ${pct(c.sd)} | ${pct(c.se)} | [${pct(c.lo)}, ${pct(c.hi)}] |`);
|
|
1592
|
+
}
|
|
1593
|
+
if (p6on) {
|
|
1594
|
+
const c = ci95(p6on.perPrompt);
|
|
1595
|
+
L.push(`| precision@${PROD_MENU_K} | ON | ${pct(c.mean)} | ${pct(c.sd)} | ${pct(c.se)} | [${pct(c.lo)}, ${pct(c.hi)}] |`);
|
|
1596
|
+
}
|
|
1597
|
+
L.push("");
|
|
1598
|
+
let distinguishable = "n/a";
|
|
1599
|
+
let deltaStr = "n/a";
|
|
1600
|
+
if (p6off && p6on) {
|
|
1601
|
+
const cOff = ci95(p6off.perPrompt);
|
|
1602
|
+
const cOn = ci95(p6on.perPrompt);
|
|
1603
|
+
const delta = cOn.mean - cOff.mean;
|
|
1604
|
+
const seDelta = Math.sqrt(cOff.se ** 2 + cOn.se ** 2);
|
|
1605
|
+
const thresholdPts = 1.96 * seDelta;
|
|
1606
|
+
distinguishable = Math.abs(delta) > thresholdPts ? "YES" : "NO — within noise";
|
|
1607
|
+
deltaStr = `${pts(delta)} (SE_delta=${pts(seDelta)}, 95% threshold=±${pts(thresholdPts)})`;
|
|
1608
|
+
L.push(`**Scope ON−OFF delta (precision@${PROD_MENU_K}):** ${deltaStr} — **${distinguishable}**\n`);
|
|
1609
|
+
}
|
|
1610
|
+
// =====================================================================
|
|
1611
|
+
// §16 — DECISION THRESHOLDS (spec §9) — filled with measured values
|
|
1612
|
+
// =====================================================================
|
|
1613
|
+
L.push("## 16. Decision thresholds (spec §9) — measured values + triggered actions\n");
|
|
1614
|
+
const noiseRate6off = m6off ? m6off.noiseRate : NaN;
|
|
1615
|
+
const noiseHighTriggered = Number.isFinite(noiseRate6off) && noiseRate6off > 0.3;
|
|
1616
|
+
// Worst similarity band with meaningful sample (n>=5), excluding n/a.
|
|
1617
|
+
let worstBand = null;
|
|
1618
|
+
for (const [label, e] of byBucket.entries()) {
|
|
1619
|
+
if (label.startsWith("n/a"))
|
|
1620
|
+
continue;
|
|
1621
|
+
if (e.total < 5)
|
|
1622
|
+
continue;
|
|
1623
|
+
const rate = e.noise / e.total;
|
|
1624
|
+
if (!worstBand || rate > worstBand.rate)
|
|
1625
|
+
worstBand = { label, rate, n: e.total };
|
|
1626
|
+
}
|
|
1627
|
+
const bandTriggered = worstBand !== null && worstBand.rate >= 0.6;
|
|
1628
|
+
const ftsE = bySrc.get("fts");
|
|
1629
|
+
const vecE = bySrc.get("vector");
|
|
1630
|
+
const ftsNoiseRate = ftsE && ftsE.total > 0 ? ftsE.noise / ftsE.total : NaN;
|
|
1631
|
+
const vecNoiseRate = vecE && vecE.total > 0 ? vecE.noise / vecE.total : NaN;
|
|
1632
|
+
const ftsRatio = Number.isFinite(ftsNoiseRate) && Number.isFinite(vecNoiseRate) && vecNoiseRate > 0 ? ftsNoiseRate / vecNoiseRate : NaN;
|
|
1633
|
+
const ftsTriggered = Number.isFinite(ftsRatio) && ftsRatio > 1.5;
|
|
1634
|
+
const snippetFailOffRate = snippetFailureRate(gapOff);
|
|
1635
|
+
const snippetFailTriggered = Number.isFinite(snippetFailOffRate) && snippetFailOffRate > 0.2;
|
|
1636
|
+
// Baseline is now PROD_TITLE_CHARS (150), not 100 — 300 was dropped from the
|
|
1637
|
+
// sweep (replaced by 120) in the 2026-08-02 post-rewrite run config.
|
|
1638
|
+
const ciBase = sweepVariantCi.get(String(PROD_TITLE_CHARS));
|
|
1639
|
+
const ciLong = sweepVariantCi.get("200");
|
|
1640
|
+
const deltaBaseToLong = ciBase && ciLong ? ciLong.mean - ciBase.mean : NaN;
|
|
1641
|
+
const lengthHelpsTriggered = Number.isFinite(deltaBaseToLong) && deltaBaseToLong >= 0.05;
|
|
1642
|
+
// Owner question (2026-08-02): can maxLen be LOWERED 150→120 without losing
|
|
1643
|
+
// signal (token savings)? "No meaningful loss" = delta not more negative
|
|
1644
|
+
// than -5pts.
|
|
1645
|
+
const ciShort = sweepVariantCi.get("120");
|
|
1646
|
+
const deltaBaseToShort = ciBase && ciShort ? ciShort.mean - ciBase.mean : NaN;
|
|
1647
|
+
const canLowerTo120 = Number.isFinite(deltaBaseToShort) && deltaBaseToShort >= -0.05;
|
|
1648
|
+
const bubbleGapPts = offMenuRel.rate - offBubRel.rate; // menu minus bubble
|
|
1649
|
+
const bubbleWithin5 = Number.isFinite(bubbleGapPts) && Math.abs(bubbleGapPts) <= 0.05;
|
|
1650
|
+
const bubbleMuchLower = Number.isFinite(bubbleGapPts) && bubbleGapPts > 0.15;
|
|
1651
|
+
const topLoaded = offTop.total > 0 && offBot.total > 0 && offTop.rate > offBot.rate;
|
|
1652
|
+
const topChronicNoise = chronicNoise[0] ?? null;
|
|
1653
|
+
const chronicTriggered = topChronicNoise !== null && topChronicNoise.noisePrompts > 10;
|
|
1654
|
+
const offRedundancy = redundancySamples.filter((s) => s.mode === "off");
|
|
1655
|
+
const meanRedundantSlotsOff = offRedundancy.length > 0 ? mean(offRedundancy.map((s) => s.shown - s.nonRedundant)) : NaN;
|
|
1656
|
+
const redundancyTriggered = Number.isFinite(meanRedundantSlotsOff) && meanRedundantSlotsOff > 1.5;
|
|
1657
|
+
const scopeDeltaPts = p6off && p6on ? (ci95(p6on.perPrompt).mean - ci95(p6off.perPrompt).mean) * 100 : NaN;
|
|
1658
|
+
L.push("| finding | measured | threshold | triggered? | action |");
|
|
1659
|
+
L.push("|---|---|---|---|---|");
|
|
1660
|
+
L.push(`| Overall noise-rate (prod K=6, OFF) | ${pct(noiseRate6off)} | >30% | ${noiseHighTriggered ? "**YES**" : "no"} | Raise \`recallMinSimilarity\` (0.55 → floor indicated by the band table) |`);
|
|
1661
|
+
L.push(`| Noise concentrated below a band | worst band ${worstBand ? `${worstBand.label} (${pct(worstBand.rate)}, n=${worstBand.n})` : "n/a"} | band ≥60% noise | ${bandTriggered ? "**YES**" : "no"} | Set \`recallMinSimilarity\` at that band's lower edge |`);
|
|
1662
|
+
L.push(`| FTS-matched hits disproportionately noise | fts ${pct(ftsNoiseRate)} vs vector ${pct(vecNoiseRate)} (ratio ${Number.isFinite(ftsRatio) ? ftsRatio.toFixed(2) : "n/a"}×) | >1.5× | ${ftsTriggered ? "**YES**" : "no"} | Stop letting FTS bypass the floor (\`passesRelevanceGate\`) |`);
|
|
1663
|
+
L.push(`| Snippet failures | ${pct(snippetFailOffRate)} (OFF) | >20% | ${snippetFailTriggered ? "**YES**" : "no"} | Raise \`maxLen\` to the sweep's best variant, or switch to title+first-sentence |`);
|
|
1664
|
+
L.push(`| Longer snippet doesn't improve line-relevance (${PROD_TITLE_CHARS}→200) | Δ ${Number.isFinite(deltaBaseToLong) ? pts(deltaBaseToLong) : "n/a"} | <5pts | ${!lengthHelpsTriggered ? "**YES (no improvement)**" : "no (improves ≥5pts)"} | Keep \`maxLen=${PROD_TITLE_CHARS}\` — the info problem is distillation quality, not length |`);
|
|
1665
|
+
L.push(`| Can \`maxLen\` be LOWERED ${PROD_TITLE_CHARS}→120 without losing signal? | Δ ${Number.isFinite(deltaBaseToShort) ? pts(deltaBaseToShort) : "n/a"} | ≥-5pts (no meaningful loss) | ${canLowerTo120 ? "**YES**" : "no"} | ${canLowerTo120 ? "120 is a viable token-saving downgrade — no measurable quality loss on this evidence" : `Keep \`maxLen=${PROD_TITLE_CHARS}\` — 120 measurably loses signal`} |`);
|
|
1666
|
+
L.push(`| Bubble (Q7-8) relevant-rate within ~5pts of menu | menu ${pct(offMenuRel.rate)} vs bubble ${pct(offBubRel.rate)} (gap ${pts(bubbleGapPts)}) | within ±5pts | ${bubbleWithin5 ? "**YES**" : "no"} | Raise \`recallMaxItems\` 6→8 if token cost is acceptable |`);
|
|
1667
|
+
L.push(`| Bubble much lower than menu | gap ${pts(bubbleGapPts)} | >15pts | ${bubbleMuchLower ? "**YES**" : "no"} | Keep 6, or lower to 4 if Q5-6 are also weak |`);
|
|
1668
|
+
L.push(`| Noise top-loaded (Q1-3 worse than Q4-6) | top-3 ${pct(offTop.rate)} vs bottom-3 ${pct(offBot.rate)} | top > bottom | ${topLoaded ? "**YES**" : "no"} | Ranking bug — investigate \`computeScore\`, don't tune the cap |`);
|
|
1669
|
+
L.push(`| Chronic-noise memory | top offender pollutes ${topChronicNoise ? topChronicNoise.noisePrompts : 0} prompts${topChronicNoise ? ` (\`${topChronicNoise.memory_id.slice(0, 8)}\`)` : ""} | >10 prompts | ${chronicTriggered ? "**YES**" : "no"} | Named target: dedup / demote / re-distill |`);
|
|
1670
|
+
L.push(`| Redundancy in the production 6 | mean ${Number.isFinite(meanRedundantSlotsOff) ? meanRedundantSlotsOff.toFixed(2) : "n/a"} redundant slots (OFF) | >1.5 avg | ${redundancyTriggered ? "**YES**" : "no"} | Tighten \`dedupMergeThreshold\` or add a diversity penalty |`);
|
|
1671
|
+
L.push(`| Scope ON−OFF delta | ${Number.isFinite(scopeDeltaPts) ? scopeDeltaPts.toFixed(1) + "pts" : "n/a"} (empirical 95% threshold ±${p6off && p6on ? (1.96 * Math.sqrt(ci95(p6off.perPrompt).se ** 2 + ci95(p6on.perPrompt).se ** 2) * 100).toFixed(1) : "n/a"}pts; spec's illustrative ±8.5pts) | >8.5pts (spec illustrative) / empirical 95% CI | ${distinguishable === "YES" ? "**YES — distinguishable**" : "no — within noise"} | ${distinguishable === "YES" ? "Declaring mission domains in production is justified" : "Report \"within noise\" — do NOT ship scoping on this evidence"} |`);
|
|
1672
|
+
L.push("");
|
|
1673
|
+
// =====================================================================
|
|
1674
|
+
// §17 — what to tune next
|
|
1675
|
+
// =====================================================================
|
|
1676
|
+
L.push("## 17. What to tune next\n");
|
|
1677
|
+
const actionable = [];
|
|
1678
|
+
const withinNoise = [];
|
|
1679
|
+
if (noiseHighTriggered)
|
|
1680
|
+
actionable.push(`\`recallMinSimilarity\`: overall noise-rate ${pct(noiseRate6off)} > 30% at prod K=6 (OFF).`);
|
|
1681
|
+
else
|
|
1682
|
+
withinNoise.push(`Overall noise-rate at prod K=6 (${pct(noiseRate6off)}) is under the 30% action threshold.`);
|
|
1683
|
+
if (bandTriggered && worstBand)
|
|
1684
|
+
actionable.push(`\`recallMinSimilarity\` → ~${worstBand.label.split("–")[1] ?? worstBand.label}: similarity band ${worstBand.label} is ${pct(worstBand.rate)} noise (n=${worstBand.n}).`);
|
|
1685
|
+
if (ftsTriggered)
|
|
1686
|
+
actionable.push(`\`passesRelevanceGate\` (recall-index.ts): FTS-matched noise-rate (${pct(ftsNoiseRate)}) is ${ftsRatio.toFixed(2)}× vector's (${pct(vecNoiseRate)}) — stop letting FTS bypass the similarity floor.`);
|
|
1687
|
+
else if (Number.isFinite(ftsRatio))
|
|
1688
|
+
withinNoise.push(`FTS-vs-vector noise ratio (${ftsRatio.toFixed(2)}×) is under the 1.5× action threshold.`);
|
|
1689
|
+
if (snippetFailTriggered)
|
|
1690
|
+
actionable.push(`\`maxLen\` (recall-index.ts \`memoryTitle\`): snippet_failure_rate ${pct(snippetFailOffRate)} (OFF) > 20% — the ${PROD_MENU_K}-line menu is losing real information at the render layer, not the retrieval layer.`);
|
|
1691
|
+
else
|
|
1692
|
+
withinNoise.push(`snippet_failure_rate (${pct(snippetFailOffRate)}, OFF) is under the 20% action threshold.`);
|
|
1693
|
+
if (!lengthHelpsTriggered)
|
|
1694
|
+
withinNoise.push(`Length sweep ${PROD_TITLE_CHARS}→200: Δ ${Number.isFinite(deltaBaseToLong) ? pts(deltaBaseToLong) : "n/a"} — within noise at N=${sweepSubsetIdx.length} (±~10pts CI); do not raise \`maxLen\` further on this evidence alone. If snippet_failure IS high, the fix is likely distillation quality (writing tighter titles), not raw length.`);
|
|
1695
|
+
else
|
|
1696
|
+
actionable.push(`\`maxLen\`: ${PROD_TITLE_CHARS}→200 improved line-relevance by ${pts(deltaBaseToLong)} (≥5pts) at N=${sweepSubsetIdx.length} — worth a targeted follow-up at full N before shipping.`);
|
|
1697
|
+
if (canLowerTo120)
|
|
1698
|
+
actionable.push(`\`maxLen\`: ${PROD_TITLE_CHARS}→120 shows Δ ${pts(deltaBaseToShort)} (no meaningful loss, ≥-5pts) at N=${sweepSubsetIdx.length} — a token-saving downgrade the owner asked about is evidence-backed.`);
|
|
1699
|
+
else
|
|
1700
|
+
withinNoise.push(`\`maxLen\` ${PROD_TITLE_CHARS}→120: Δ ${Number.isFinite(deltaBaseToShort) ? pts(deltaBaseToShort) : "n/a"} — do NOT lower to 120 on this evidence, it measurably costs signal.`);
|
|
1701
|
+
if (bubbleWithin5)
|
|
1702
|
+
actionable.push(`\`recallMaxItems\` 6→8: bubble (Q7-8, ${pct(offBubRel.rate)}) is within 5pts of the menu (${pct(offMenuRel.rate)}) — check token cost (§12) before raising.`);
|
|
1703
|
+
else if (bubbleMuchLower)
|
|
1704
|
+
withinNoise.push(`Bubble relevant-rate (${pct(offBubRel.rate)}) is >15pts below the menu (${pct(offMenuRel.rate)}) — \`recallMaxItems=6\` is not leaving good memories on the table.`);
|
|
1705
|
+
if (topLoaded)
|
|
1706
|
+
actionable.push(`\`computeScore\` (retrieval.ts): noise is top-loaded (Q1-3 ${pct(offTop.rate)} > Q4-6 ${pct(offBot.rate)}) — this is a RANKING bug, not a cap-size question.`);
|
|
1707
|
+
else
|
|
1708
|
+
withinNoise.push(`Top-3 vs bottom-3 noise split (${pct(offTop.rate)} vs ${pct(offBot.rate)}) shows no top-loading.`);
|
|
1709
|
+
if (chronicTriggered && topChronicNoise)
|
|
1710
|
+
actionable.push(`Dedup/demote/re-distill \`${topChronicNoise.memory_id.slice(0, 8)}\` (${topChronicNoise.memory_type ?? "unknown type"}): pollutes ${topChronicNoise.noisePrompts} prompts.`);
|
|
1711
|
+
else
|
|
1712
|
+
withinNoise.push(`No single memory pollutes >10 prompts (top offender: ${topChronicNoise ? `${topChronicNoise.noisePrompts} prompts` : "none"}).`);
|
|
1713
|
+
if (redundancyTriggered)
|
|
1714
|
+
actionable.push(`\`dedupMergeThreshold\` / diversity penalty: mean ${meanRedundantSlotsOff.toFixed(2)} redundant slots in the production 6 (OFF) > 1.5.`);
|
|
1715
|
+
else
|
|
1716
|
+
withinNoise.push(`Redundancy in the production 6 (mean ${Number.isFinite(meanRedundantSlotsOff) ? meanRedundantSlotsOff.toFixed(2) : "n/a"} redundant slots, OFF) is under the 1.5 action threshold.`);
|
|
1717
|
+
if (distinguishable === "YES")
|
|
1718
|
+
actionable.push(`Scope (\`missionDomains\`/\`project\` affinity): ON−OFF delta ${deltaStr} is distinguishable at 95% — declaring mission domains in production is evidence-backed.`);
|
|
1719
|
+
else
|
|
1720
|
+
withinNoise.push(`Scope ON−OFF delta (${deltaStr}) is within noise at N=${prompts.length} — do NOT ship mission-domain scoping on this evidence; it would need N≈1800 to detect a ~2pt true effect (spec §8).`);
|
|
1721
|
+
L.push("**Actionable now:**\n");
|
|
1722
|
+
if (actionable.length === 0)
|
|
1723
|
+
L.push("_(none crossed a decision threshold this run)_\n");
|
|
1724
|
+
else
|
|
1725
|
+
for (const a of actionable)
|
|
1726
|
+
L.push(`- ${a}`);
|
|
1727
|
+
L.push("\n**Within noise (not actionable on this evidence):**\n");
|
|
1728
|
+
if (withinNoise.length === 0)
|
|
1729
|
+
L.push("_(none)_\n");
|
|
1730
|
+
else
|
|
1731
|
+
for (const w of withinNoise)
|
|
1732
|
+
L.push(`- ${w}`);
|
|
1733
|
+
L.push("");
|
|
1734
|
+
if (judgeState.errorUnits > 0) {
|
|
1735
|
+
L.push(`_${judgeState.errorUnits}/${judgeState.completedUnits} judge units returned \`judge_error\` (excluded from every ` +
|
|
1736
|
+
`denominator above) — see \`${judgeState.sidecarPath}\` for the raw error messages._\n`);
|
|
1737
|
+
}
|
|
1738
|
+
return L.join("\n");
|
|
1739
|
+
}
|
|
1740
|
+
// ---------------------------------------------------------------------------
|
|
1741
|
+
// main
|
|
1742
|
+
// ---------------------------------------------------------------------------
|
|
1743
|
+
function usage() {
|
|
1744
|
+
console.error("Usage: relevance-eval.js <snapshot.db> [prompts.json] [report.md] " +
|
|
1745
|
+
"[--judge-delay-ms=2000] [--max-calls=900] [--resume] " +
|
|
1746
|
+
"[--verdicts-json=path] [--verdicts-jsonl=path]\n" +
|
|
1747
|
+
" <snapshot.db> — REQUIRED. Readonly bedrock snapshot (opened via openSnapshot).\n" +
|
|
1748
|
+
" [prompts.json] — omitted: build the v2 corpus fresh (20/source × 5 sources, spec §2),\n" +
|
|
1749
|
+
" saved to data/prompts.json. A valid existing v2 set is reused verbatim.\n" +
|
|
1750
|
+
" <path.json>: load a saved stable set verbatim (no resample).\n" +
|
|
1751
|
+
" [report.md] — output report path (default data/relevance-eval-report.md).\n" +
|
|
1752
|
+
" --verdicts-json / --verdicts-jsonl — override the raw-verdicts JSON / checkpoint-sidecar\n" +
|
|
1753
|
+
" JSONL paths (default data/relevance-verdicts.json[l]). ALWAYS override\n" +
|
|
1754
|
+
" these for a re-run against a new snapshot — otherwise a re-run silently\n" +
|
|
1755
|
+
" clobbers a prior baseline's raw verdicts.\n" +
|
|
1756
|
+
"Env: ZAI_API_KEY (required — GLM-5.2 via z.ai is the judge)\n" +
|
|
1757
|
+
"Preconditions: /tmp/hermes-{lenny,raider,nano}-state.db (readonly Hermes state DBs) when\n" +
|
|
1758
|
+
"building the corpus fresh; ~/.claude/projects/.../infrastructure + .../aironic-marine dirs.");
|
|
1759
|
+
process.exitCode = 1;
|
|
1760
|
+
process.exit(1);
|
|
1761
|
+
}
|
|
1762
|
+
async function main() {
|
|
1763
|
+
// Pin all retrieval knobs to shipped defaults — measure the SHIPPED ranker.
|
|
1764
|
+
(0, retrieval_js_1.configureScoring)();
|
|
1765
|
+
(0, retrieval_js_1.configureDecay)();
|
|
1766
|
+
(0, retrieval_js_1.configureRecall)();
|
|
1767
|
+
(0, retrieval_js_1.configureSessionIntent)();
|
|
1768
|
+
const { positionals, judgeDelayMs, maxCalls, resume, verdictsJsonPath, verdictsJsonlPath } = parseArgs(process.argv.slice(2));
|
|
1769
|
+
const [snapshotArg, promptsSourceArg, reportArg] = positionals;
|
|
1770
|
+
if (!snapshotArg)
|
|
1771
|
+
usage();
|
|
1772
|
+
const apiKey = process.env.ZAI_API_KEY;
|
|
1773
|
+
if (!apiKey) {
|
|
1774
|
+
console.error("relevance-eval: ZAI_API_KEY env var is required (GLM-5.2 via z.ai is the judge)");
|
|
1775
|
+
process.exitCode = 1;
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
// ---- prompt corpus (spec §2) ----
|
|
1779
|
+
let prompts;
|
|
1780
|
+
let promptsPath;
|
|
1781
|
+
if (promptsSourceArg) {
|
|
1782
|
+
promptsPath = promptsSourceArg;
|
|
1783
|
+
prompts = loadPromptsJson(promptsSourceArg);
|
|
1784
|
+
console.log(`[relevance-eval] loaded ${prompts.length} prompts from ${promptsPath}`);
|
|
1785
|
+
}
|
|
1786
|
+
else {
|
|
1787
|
+
promptsPath = PROMPTS_SAVE_PATH;
|
|
1788
|
+
if ((0, node_fs_1.existsSync)(promptsPath)) {
|
|
1789
|
+
const existing = loadPromptsJson(promptsPath);
|
|
1790
|
+
if (isValidV2PromptSet(existing)) {
|
|
1791
|
+
prompts = existing;
|
|
1792
|
+
console.log(`[relevance-eval] reusing existing v2 prompt set: ${promptsPath} (${prompts.length} prompts, stable across runs)`);
|
|
1793
|
+
}
|
|
1794
|
+
else {
|
|
1795
|
+
console.log(`[relevance-eval] existing ${promptsPath} does not satisfy the v2 corpus contract (5 sources) — resampling`);
|
|
1796
|
+
prompts = buildV2Corpus();
|
|
1797
|
+
savePromptsJson(promptsPath, prompts);
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
else {
|
|
1801
|
+
prompts = buildV2Corpus();
|
|
1802
|
+
savePromptsJson(promptsPath, prompts);
|
|
1803
|
+
}
|
|
1804
|
+
console.log(`[relevance-eval] corpus: ${prompts.length} prompts across ${new Set(prompts.map((p) => p.sourceTag)).size}/${SOURCE_TAGS.length} sources; saved to ${promptsPath}`);
|
|
1805
|
+
}
|
|
1806
|
+
const sweepSubsetIdx = computeSweepSubsetIndices(prompts);
|
|
1807
|
+
const db = (0, eval_db_js_1.openSnapshot)(snapshotArg);
|
|
1808
|
+
try {
|
|
1809
|
+
console.log(`[relevance-eval] snapshot opened readonly: ${snapshotArg} (${prompts.length} prompts × 2 modes = ${prompts.length * 2} retrieve() calls)`);
|
|
1810
|
+
console.log("[relevance-eval] loading bge-small-en-v1.5 + replaying retrieve()...");
|
|
1811
|
+
const t0 = Date.now();
|
|
1812
|
+
const results = await runRetrieveSweep(db, prompts);
|
|
1813
|
+
console.log(`[relevance-eval] retrieve sweep done in ${Date.now() - t0}ms (${results.length} prompt×mode batches, LLM-free)`);
|
|
1814
|
+
// ---- judge run state (rate limiting + resumability, spec §6b) ----
|
|
1815
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(verdictsJsonlPath), { recursive: true });
|
|
1816
|
+
if (!resume)
|
|
1817
|
+
(0, node_fs_1.writeFileSync)(verdictsJsonlPath, "", "utf-8"); // fresh run: don't let a stale sidecar leak in.
|
|
1818
|
+
const resumedMap = resume ? loadResumeMap(verdictsJsonlPath) : new Map();
|
|
1819
|
+
let preResumedErrors = 0;
|
|
1820
|
+
for (const v of resumedMap.values()) {
|
|
1821
|
+
if (v && typeof v === "object" && "judgeError" in v)
|
|
1822
|
+
preResumedErrors++;
|
|
1823
|
+
}
|
|
1824
|
+
const judgeState = {
|
|
1825
|
+
apiKey,
|
|
1826
|
+
judgeDelayMs,
|
|
1827
|
+
maxCalls,
|
|
1828
|
+
totalCalls: 0,
|
|
1829
|
+
totalRetries: 0,
|
|
1830
|
+
completedUnits: resumedMap.size,
|
|
1831
|
+
errorUnits: preResumedErrors,
|
|
1832
|
+
startTime: Date.now(),
|
|
1833
|
+
resumed: resumedMap,
|
|
1834
|
+
sidecarPath: verdictsJsonlPath,
|
|
1835
|
+
aborted: null,
|
|
1836
|
+
};
|
|
1837
|
+
if (resume) {
|
|
1838
|
+
console.log(`[relevance-eval] --resume: ${resumedMap.size} units already checkpointed in ${verdictsJsonlPath}`);
|
|
1839
|
+
}
|
|
1840
|
+
const sweepRows = [];
|
|
1841
|
+
try {
|
|
1842
|
+
// ---- Phase 1: full_verdict (all prompts × both modes) ----
|
|
1843
|
+
console.log(`[relevance-eval] phase 1/4: full-content judging (${results.length} units)...`);
|
|
1844
|
+
for (const r of results) {
|
|
1845
|
+
const key = `full|${prompts.indexOf(r.prompt)}|${r.mode}`;
|
|
1846
|
+
const expectedLabels = new Set(r.topIds.map((_, i) => `Q${i + 1}`));
|
|
1847
|
+
const promptText = buildFullContentJudgePrompt(r.prompt.prompt, r.topIds, r.contents);
|
|
1848
|
+
r.fullVerdicts = await judgeVerdictUnit(judgeState, key, promptText, expectedLabels);
|
|
1849
|
+
}
|
|
1850
|
+
// ---- Phase 2: line_verdict at maxLen=PROD_TITLE_CHARS (production render) ----
|
|
1851
|
+
console.log(`[relevance-eval] phase 2/4: line-only judging at production maxLen=${PROD_TITLE_CHARS} (${results.length} units)...`);
|
|
1852
|
+
for (const r of results) {
|
|
1853
|
+
const key = `line|${prompts.indexOf(r.prompt)}|${r.mode}`;
|
|
1854
|
+
const expectedLabels = new Set(r.topIds.map((_, i) => `Q${i + 1}`));
|
|
1855
|
+
const lines = {};
|
|
1856
|
+
for (const id of r.topIds)
|
|
1857
|
+
lines[id] = (0, recall_index_js_1.formatIndexLine)(r.rows[id], PROD_TITLE_CHARS);
|
|
1858
|
+
const promptText = buildLineJudgePrompt(r.prompt.prompt, r.topIds, lines);
|
|
1859
|
+
r.lineVerdicts = await judgeVerdictUnit(judgeState, key, promptText, expectedLabels);
|
|
1860
|
+
}
|
|
1861
|
+
// ---- Phase 3: snippet-length sweep (40-subset, scope OFF, 4 variants) ----
|
|
1862
|
+
const offResultByPromptIdx = new Map();
|
|
1863
|
+
for (const r of results) {
|
|
1864
|
+
if (r.mode === "off")
|
|
1865
|
+
offResultByPromptIdx.set(prompts.indexOf(r.prompt), r);
|
|
1866
|
+
}
|
|
1867
|
+
console.log(`[relevance-eval] phase 3/4: snippet-length sweep (${sweepSubsetIdx.length} prompts × ${SWEEP_VARIANTS.length} variants)...`);
|
|
1868
|
+
for (const promptIdx of sweepSubsetIdx) {
|
|
1869
|
+
const r = offResultByPromptIdx.get(promptIdx);
|
|
1870
|
+
if (!r)
|
|
1871
|
+
continue;
|
|
1872
|
+
for (const variant of SWEEP_VARIANTS) {
|
|
1873
|
+
const key = `sweep|${promptIdx}|off|${variant}`;
|
|
1874
|
+
const expectedLabels = new Set(r.topIds.map((_, i) => `Q${i + 1}`));
|
|
1875
|
+
const lines = {};
|
|
1876
|
+
for (const id of r.topIds)
|
|
1877
|
+
lines[id] = renderVariantLine(r.rows[id], variant);
|
|
1878
|
+
const promptText = buildLineJudgePrompt(r.prompt.prompt, r.topIds, lines);
|
|
1879
|
+
const verdicts = await judgeVerdictUnit(judgeState, key, promptText, expectedLabels);
|
|
1880
|
+
sweepRows.push({ promptIdx, variant, verdicts, lines });
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
// ---- Phase 4: redundancy over the production 6 (per prompt × mode) ----
|
|
1884
|
+
console.log(`[relevance-eval] phase 4/4: redundancy judging (${results.length} units, some skipped when <2 shown)...`);
|
|
1885
|
+
for (const r of results) {
|
|
1886
|
+
const rowsArr = r.topIds.map((id) => r.rows[id]).filter(Boolean);
|
|
1887
|
+
const { shownIds, lines } = renderProductionBlock(rowsArr, PROD_MIN_SIMILARITY, PROD_MENU_K, PROD_TITLE_CHARS);
|
|
1888
|
+
if (shownIds.length < 2) {
|
|
1889
|
+
r.redundancy = { skipped: true, shown: shownIds.length };
|
|
1890
|
+
continue;
|
|
1891
|
+
}
|
|
1892
|
+
const key = `redundancy|${prompts.indexOf(r.prompt)}|${r.mode}`;
|
|
1893
|
+
const shownLines = shownIds.map((id) => lines[id]);
|
|
1894
|
+
const promptText = buildRedundancyPrompt(r.prompt.prompt, shownLines);
|
|
1895
|
+
const parsed = await judgeRedundancyUnit(judgeState, key, promptText, shownIds.length);
|
|
1896
|
+
r.redundancy = "judgeError" in parsed ? parsed : { shown: shownIds.length, ...parsed };
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
catch (err) {
|
|
1900
|
+
if (err instanceof RunAbortedError) {
|
|
1901
|
+
console.error(`[relevance-eval] RUN ABORTED: ${err.message}`);
|
|
1902
|
+
judgeState.aborted = judgeState.aborted ?? { reason: err.message };
|
|
1903
|
+
}
|
|
1904
|
+
else {
|
|
1905
|
+
throw err;
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
// ---- persist raw verdicts (source of truth, spec §6) ----
|
|
1909
|
+
const verdictRows = buildVerdictRows(prompts, results, sweepRows);
|
|
1910
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(verdictsJsonPath), { recursive: true });
|
|
1911
|
+
(0, node_fs_1.writeFileSync)(verdictsJsonPath, JSON.stringify(verdictRows, null, 2), "utf-8");
|
|
1912
|
+
console.log(`[relevance-eval] raw verdicts saved: ${verdictsJsonPath} (${verdictRows.length} rows), checkpoint sidecar: ${verdictsJsonlPath}`);
|
|
1913
|
+
const report = renderReport({
|
|
1914
|
+
snapshotPath: snapshotArg,
|
|
1915
|
+
promptsPath,
|
|
1916
|
+
verdictsJsonPath,
|
|
1917
|
+
prompts,
|
|
1918
|
+
results,
|
|
1919
|
+
sweepRows,
|
|
1920
|
+
sweepSubsetIdx,
|
|
1921
|
+
verdictRows,
|
|
1922
|
+
judgeState,
|
|
1923
|
+
generatedAt: new Date().toISOString(),
|
|
1924
|
+
db,
|
|
1925
|
+
});
|
|
1926
|
+
const reportPath = reportArg ?? DEFAULT_REPORT_PATH;
|
|
1927
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(reportPath), { recursive: true });
|
|
1928
|
+
(0, node_fs_1.writeFileSync)(reportPath, report, "utf-8");
|
|
1929
|
+
console.log("\n=== relevance-eval summary ===");
|
|
1930
|
+
const off6 = computeMetricsAtK(results.filter((r) => r.mode === "off"), PROD_MENU_K);
|
|
1931
|
+
const on6 = computeMetricsAtK(results.filter((r) => r.mode === "on"), PROD_MENU_K);
|
|
1932
|
+
console.log(`precision@${PROD_MENU_K} (menu) OFF ${off6 ? pct(off6.precision) : "n/a"} → ON ${on6 ? pct(on6.precision) : "n/a"}`);
|
|
1933
|
+
const gapOffSummary = computeSnippetGap(results.filter((r) => r.mode === "off"));
|
|
1934
|
+
console.log(`snippet_failure_rate (OFF) ${gapOffSummary.fullRelevantTotal > 0 ? pct(gapOffSummary.snippetFailure / gapOffSummary.fullRelevantTotal) : "n/a"}`);
|
|
1935
|
+
console.log(`judge units: ${judgeState.completedUnits} completed, ${judgeState.errorUnits} judge_error, ${judgeState.totalCalls} fresh calls, ${judgeState.totalRetries} retries`);
|
|
1936
|
+
if (judgeState.aborted)
|
|
1937
|
+
console.log(`ABORTED: ${judgeState.aborted.reason}`);
|
|
1938
|
+
console.log(`\n[relevance-eval] full report: ${reportPath}`);
|
|
1939
|
+
if (judgeState.aborted)
|
|
1940
|
+
process.exitCode = 1;
|
|
1941
|
+
}
|
|
1942
|
+
finally {
|
|
1943
|
+
try {
|
|
1944
|
+
db.close();
|
|
1945
|
+
}
|
|
1946
|
+
catch {
|
|
1947
|
+
/* already closed */
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
main().catch((err) => {
|
|
1952
|
+
console.error("[relevance-eval] FAILED:", err instanceof Error ? err.stack : String(err));
|
|
1953
|
+
process.exitCode = 1;
|
|
1954
|
+
});
|