@ilaomianyang/dsh-memory-graph 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +259 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +466 -0
  4. package/README.md +459 -0
  5. package/client.js +1953 -0
  6. package/cordis.patch.yml +13 -0
  7. package/docs/auto-settings.png +0 -0
  8. package/docs/distill-button.png +0 -0
  9. package/docs/graph-dark.png +0 -0
  10. package/docs/graph-light.png +0 -0
  11. package/docs/session-tab-cards.png +0 -0
  12. package/docs/session-tab-open.png +0 -0
  13. package/docs/session-tab.png +0 -0
  14. package/docs/vault-panel.png +0 -0
  15. package/eval/REPORT.md +437 -0
  16. package/eval/questions.json +277 -0
  17. package/eval/run.mjs +460 -0
  18. package/eval/seed.mjs +489 -0
  19. package/icon.svg +26 -0
  20. package/index.js +243 -0
  21. package/locale/en.json +6 -0
  22. package/locale/zh.json +6 -0
  23. package/package.json +77 -0
  24. package/src/auto.js +194 -0
  25. package/src/capture.js +237 -0
  26. package/src/config.js +280 -0
  27. package/src/distillapi.js +30 -0
  28. package/src/distillrun.js +106 -0
  29. package/src/extract.js +660 -0
  30. package/src/graph.js +1582 -0
  31. package/src/graphdata.js +475 -0
  32. package/src/http.js +32 -0
  33. package/src/pageapi.js +40 -0
  34. package/src/privacy.js +78 -0
  35. package/src/schema.js +323 -0
  36. package/src/search.js +317 -0
  37. package/src/tools.js +534 -0
  38. package/src/vault.js +888 -0
  39. package/test/auto.mjs +295 -0
  40. package/test/capture.mjs +264 -0
  41. package/test/client.mjs +1035 -0
  42. package/test/distillapi.mjs +212 -0
  43. package/test/extract.mjs +660 -0
  44. package/test/graphdata.mjs +295 -0
  45. package/test/live-supersede.mjs +78 -0
  46. package/test/live.mjs +156 -0
  47. package/test/page.mjs +116 -0
  48. package/test/schema.mjs +256 -0
  49. package/test/smoke.mjs +428 -0
  50. package/test/tools.mjs +238 -0
package/eval/run.mjs ADDED
@@ -0,0 +1,460 @@
1
+ /**
2
+ * Retrieval eval: score the real searchCards against a seeded vault.
3
+ *
4
+ * node eval/run.mjs
5
+ *
6
+ * Metrics
7
+ * recall@1 the top hit is one of the expected cards
8
+ * recall@k |expected ∩ top-k| / |expected|, averaged over the questions
9
+ * MRR 1 / rank of the first expected card (0 when nothing is found)
10
+ * negative a question about something the vault never recorded counts as a
11
+ * false positive when its top hit still carries real weight
12
+ *
13
+ * A temporal question passes only when the card that is still true is in the
14
+ * top-k AND no superseded card outranks it: finding both and picking the stale
15
+ * one is the failure this category exists to catch.
16
+ *
17
+ * The history group (kind "history" in questions.json) is scored on its own and
18
+ * never enters the numbers above. Each pair asks about one topic twice, once
19
+ * about the past and once about now; a side passes when its expected card is in
20
+ * the top-k and the card that answers the other half of the pair is not above
21
+ * it, and a pair passes only when both sides do. Its gate (pair accuracy >= 0.8)
22
+ * is separate.
23
+ *
24
+ * The thresholds are part of the task, not of the tuning: a run below them
25
+ * exits non-zero and leaves the vault on disk for inspection.
26
+ */
27
+ import assert from 'node:assert/strict'
28
+ import fsp from 'node:fs/promises'
29
+ import path from 'node:path'
30
+ import { fileURLToPath } from 'node:url'
31
+
32
+ import { searchCards } from '../src/search.js'
33
+ import { FIXTURE_CARDS, bridgeEntities, seed } from './seed.mjs'
34
+
35
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
36
+
37
+ const LIMIT = 5
38
+ const HOPS = 1
39
+ /** A negative question "hit something" only above this score: the recency fallback tops out near 0.05. */
40
+ const NEGATIVE_SCORE = 0.5
41
+ const GATE_RECALL5 = 0.8
42
+ const GATE_NEGATIVE_FP = 1
43
+ /** Share of history pairs that must get both eras right. */
44
+ const GATE_PAIRS = 0.8
45
+
46
+ const KINDS = ['keyword', 'entity-bridge', 'temporal', 'negative']
47
+ const HISTORY_KIND = 'history'
48
+
49
+ /* ------------------------------------------------------------------ helpers */
50
+
51
+ const results = []
52
+ function check(name, fn) {
53
+ try {
54
+ fn()
55
+ results.push({ ok: true, name })
56
+ } catch (error) {
57
+ results.push({ ok: false, name, message: error.message })
58
+ }
59
+ }
60
+
61
+ function width(text) {
62
+ let n = 0
63
+ for (const char of String(text)) {
64
+ const code = char.codePointAt(0)
65
+ n += code > 0x1100 && (code <= 0x115f || (code >= 0x2e80 && code <= 0xa4cf) || (code >= 0xac00 && code <= 0xd7a3) || (code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe30 && code <= 0xfe6f) || (code >= 0xff00 && code <= 0xff60)) ? 2 : 1
66
+ }
67
+ return n
68
+ }
69
+
70
+ function pad(text, size, align = 'left') {
71
+ const fill = ' '.repeat(Math.max(0, size - width(text)))
72
+ return align === 'right' ? `${fill}${text}` : `${text}${fill}`
73
+ }
74
+
75
+ const fixed = (value, digits = 2) => (Number.isFinite(value) ? value.toFixed(digits) : '-')
76
+
77
+ function table(headers, rows) {
78
+ const sizes = headers.map((header, i) =>
79
+ Math.max(width(header), ...rows.map((row) => width(row[i] ?? ''))),
80
+ )
81
+ const line = (cells) => cells.map((cell, i) => pad(cell, sizes[i], i === 0 ? 'left' : 'right')).join(' ')
82
+ return [
83
+ line(headers),
84
+ sizes.map((size) => '-'.repeat(size)).join(' '),
85
+ ...rows.map(line),
86
+ ].join('\n')
87
+ }
88
+
89
+ /* ------------------------------------------------------------------ scoring */
90
+
91
+ /**
92
+ * @returns {{
93
+ * id: string, kind: string, query: string, results: object[], ranks: number[],
94
+ * top1: number, recall1: number, recall5: number, reciprocalRank: number,
95
+ * demoteOk: boolean, pass: boolean, falsePositive: boolean
96
+ * }}
97
+ */
98
+ function evaluate(question, cards) {
99
+ const options = { limit: LIMIT, hops: HOPS }
100
+ if (question.project) options.project = question.project
101
+ if (question.kinds) options.kinds = question.kinds
102
+ const hits = searchCards(cards, question.query, options)
103
+
104
+ const rankOf = (id) => {
105
+ const at = hits.findIndex((hit) => hit.card.id === id)
106
+ return at === -1 ? 0 : at + 1
107
+ }
108
+ const ranks = (question.expect ?? []).map(rankOf)
109
+ const found = ranks.filter((rank) => rank > 0)
110
+ const best = found.length > 0 ? Math.min(...found) : 0
111
+
112
+ const recall5 = ranks.length === 0 ? 0 : ranks.filter((rank) => rank > 0 && rank <= LIMIT).length / ranks.length
113
+ const top1 = hits.length > 0 && (question.expect ?? []).includes(hits[0].card.id) ? 1 : 0
114
+ const reciprocalRank = best > 0 ? 1 / best : 0
115
+
116
+ // No superseded card may sit above a card that is still true.
117
+ let demoteOk = true
118
+ for (const stale of question.demote ?? []) {
119
+ const staleRank = rankOf(stale)
120
+ if (staleRank === 0) continue
121
+ for (const rank of ranks) {
122
+ if (rank > 0 && rank > staleRank) demoteOk = false
123
+ }
124
+ }
125
+
126
+ const temporal = question.kind === 'temporal'
127
+ const negative = question.kind === 'negative'
128
+ const falsePositive = negative && hits.length > 0 && hits[0].score >= NEGATIVE_SCORE
129
+
130
+ // History pairs: the expected card has to be found AND has to outrank the card
131
+ // that answers the other half of the pair. Returning both and ordering them
132
+ // wrongly is exactly the failure a "which era is this?" question can hide.
133
+ const rivalRanks = (question.rival ?? []).map(rankOf)
134
+ const rivalOk = rivalRanks.every((rank) => rank === 0 || rank > best)
135
+
136
+ return {
137
+ id: question.id,
138
+ kind: question.kind,
139
+ pair: question.pair ?? null,
140
+ side: question.side ?? null,
141
+ query: question.query,
142
+ results: hits,
143
+ ranks,
144
+ rivalRanks,
145
+ rivalOk,
146
+ top1: negative ? 0 : top1,
147
+ recall1: negative ? 0 : top1,
148
+ recall5: negative ? 0 : recall5,
149
+ reciprocalRank: negative ? 0 : reciprocalRank,
150
+ demoteOk,
151
+ pass: negative
152
+ ? !falsePositive
153
+ : question.kind === HISTORY_KIND
154
+ ? recall5 === 1 && rivalOk
155
+ : temporal
156
+ ? recall5 === 1 && demoteOk
157
+ : top1 === 1,
158
+ falsePositive,
159
+ fallback: hits.length > 0 && hits.every((hit) => hit.via === 'recency'),
160
+ topScore: hits[0]?.score ?? 0,
161
+ }
162
+ }
163
+
164
+ const mean = (values) => (values.length === 0 ? 0 : values.reduce((sum, n) => sum + n, 0) / values.length)
165
+
166
+ /* --------------------------------------------------------------------- main */
167
+
168
+ const wanted = JSON.parse(await fsp.readFile(path.join(HERE, 'questions.json'), 'utf8'))
169
+ const { dir, cards, stats } = await seed()
170
+ const byId = new Map(cards.map((card) => [card.id, card]))
171
+ const bridges = bridgeEntities(cards)
172
+ const mainQuestions = wanted.questions.filter((question) => question.kind !== HISTORY_KIND)
173
+ const historyQuestions = wanted.questions.filter((question) => question.kind === HISTORY_KIND)
174
+ /** Chains are counted from the fixture itself, not written down twice. */
175
+ const chains = FIXTURE_CARDS.filter((card) => (card.supersedes ?? []).length > 0)
176
+
177
+ /* ------------------------------------------------------- the eval set itself */
178
+
179
+ check(`fixture has ${stats.cards.total} cards`, () => {
180
+ assert.ok(stats.cards.total >= 30 && stats.cards.total <= 40, String(stats.cards.total))
181
+ })
182
+ check('fixture covers all five kinds', () => {
183
+ for (const kind of ['decision', 'fact', 'open_question', 'preference', 'note']) {
184
+ assert.ok((stats.cards.byKind[kind] ?? 0) > 0, `${kind} missing`)
185
+ }
186
+ })
187
+ check('fixture spans three projects', () => {
188
+ assert.deepEqual([...stats.projects].sort(), ['blog-engine', 'infra', 'memory-graph'])
189
+ })
190
+ check(`fixture has ${bridges.length} cross-project entities (need 6)`, () => {
191
+ assert.ok(bridges.length >= 6, bridges.map((bridge) => bridge.slug).join(','))
192
+ })
193
+ check(`fixture has ${chains.length} supersede chains`, () => {
194
+ const superseded = cards.filter((card) => card.status === 'superseded')
195
+ assert.equal(superseded.length, chains.length)
196
+ assert.ok(superseded.every((card) => card.supersededBy && byId.has(card.supersededBy)))
197
+ })
198
+ check('fixture has one retracted card', () => {
199
+ assert.equal(cards.filter((card) => card.status === 'retracted').length, 1)
200
+ })
201
+ check(`${mainQuestions.length} scored questions, all well formed`, () => {
202
+ assert.equal(mainQuestions.length, 25)
203
+ const ids = new Set()
204
+ for (const question of mainQuestions) {
205
+ assert.ok(!ids.has(question.id), `duplicate id ${question.id}`)
206
+ ids.add(question.id)
207
+ assert.ok(KINDS.includes(question.kind), `${question.id}: bad kind ${question.kind}`)
208
+ assert.ok(typeof question.query === 'string' && question.query.trim() !== '', `${question.id}: empty query`)
209
+ assert.ok(Array.isArray(question.expect), `${question.id}: expect must be an array`)
210
+ if (question.kind === 'negative') assert.equal(question.expect.length, 0, `${question.id}: negatives expect nothing`)
211
+ else assert.ok(question.expect.length > 0, `${question.id}: no expected card`)
212
+ }
213
+ })
214
+ check('every expected id exists in the fixture', () => {
215
+ for (const question of wanted.questions) {
216
+ for (const id of [...question.expect, ...(question.demote ?? []), ...(question.rival ?? [])]) {
217
+ assert.ok(byId.has(id), `${question.id}: unknown card ${id}`)
218
+ }
219
+ }
220
+ })
221
+ const bridgeQuestions = wanted.questions.filter((q) => q.kind === 'entity-bridge')
222
+ check('entity-bridge questions name an entity shared by two projects', () => {
223
+ for (const question of bridgeQuestions) {
224
+ const needle = question.query.toLowerCase().replace(/-/g, ' ')
225
+ const named = bridges.filter((bridge) => needle.includes(bridge.slug) || needle.includes(bridge.slug.replace(/-/g, ' ')))
226
+ assert.ok(named.length > 0, `${question.id}: query names none of ${bridges.map((b) => b.slug).join(',')}`)
227
+ }
228
+ })
229
+ check('most entity-bridge questions expect cards from several projects', () => {
230
+ const multi = bridgeQuestions.filter((question) => new Set(question.expect.map((id) => byId.get(id).project)).size >= 2)
231
+ // e5 is deliberately the single-answer case: the earliest card lives in one
232
+ // project, the newer ones in another, and only the entity connects them.
233
+ assert.ok(multi.length >= bridgeQuestions.length - 1, `${multi.length} of ${bridgeQuestions.length} span projects`)
234
+ })
235
+ check('temporal questions agree with the supersede chains', () => {
236
+ for (const question of wanted.questions.filter((q) => q.kind === 'temporal')) {
237
+ const expected = byId.get(question.expect[0])
238
+ for (const id of question.demote ?? []) {
239
+ assert.equal(byId.get(id).status, 'superseded', `${question.id}: ${id} is not superseded`)
240
+ assert.equal(expected.status, 'active', `${question.id}: expects a card that is not active`)
241
+ }
242
+ if (question.mode === 'history') {
243
+ assert.equal(expected.status, 'superseded', `${question.id}: a history question must expect the retired card`)
244
+ } else {
245
+ assert.equal(expected.status, 'active', `${question.id}: expects a card that is not active`)
246
+ }
247
+ }
248
+ })
249
+ check(`${historyQuestions.length} history questions form ${historyQuestions.length / 2} complete pairs`, () => {
250
+ const byPair = new Map()
251
+ for (const question of historyQuestions) {
252
+ assert.ok(typeof question.pair === 'string' && question.pair !== '', `${question.id}: no pair`)
253
+ assert.ok(['history', 'current'].includes(question.side), `${question.id}: bad side ${question.side}`)
254
+ const list = byPair.get(question.pair) ?? []
255
+ list.push(question)
256
+ byPair.set(question.pair, list)
257
+ }
258
+ assert.ok(byPair.size >= 4, `only ${byPair.size} pairs, need at least 4`)
259
+ for (const [pair, list] of byPair) {
260
+ assert.equal(list.length, 2, `pair ${pair} has ${list.length} sides`)
261
+ assert.deepEqual(list.map((question) => question.side).sort(), ['current', 'history'], `pair ${pair}: sides`)
262
+ for (const question of list) {
263
+ assert.equal(question.expect.length, 1, `${question.id}: one expected card`)
264
+ assert.equal(question.rival.length, 1, `${question.id}: one rival card`)
265
+ const other = list.find((candidate) => candidate.id !== question.id)
266
+ assert.equal(question.rival[0], other.expect[0], `${question.id}: the rival is not the other side's answer`)
267
+ assert.notEqual(question.expect[0], question.rival[0], `${question.id}: expect and rival are the same card`)
268
+ }
269
+ }
270
+ })
271
+ check('history sides expect the retired card, current sides the one that replaced it', () => {
272
+ for (const question of historyQuestions) {
273
+ const expected = byId.get(question.expect[0])
274
+ const rival = byId.get(question.rival[0])
275
+ if (question.side === 'history') {
276
+ assert.equal(expected.status, 'superseded', `${question.id}: a history side must expect the retired card`)
277
+ assert.equal(rival.status, 'active', `${question.id}: its rival must be the active card`)
278
+ } else {
279
+ assert.equal(expected.status, 'active', `${question.id}: a current side must expect the active card`)
280
+ assert.equal(rival.status, 'superseded', `${question.id}: its rival must be the retired card`)
281
+ }
282
+ }
283
+ })
284
+
285
+ /* ---------------------------------------------------------------- the run */
286
+
287
+ const scored = mainQuestions.map((question) => evaluate(question, cards))
288
+ const historyScored = historyQuestions.map((question) => evaluate(question, cards))
289
+
290
+ /** A pair counts only when both of its sides pass. */
291
+ const pairs = [...new Set(historyScored.map((item) => item.pair))].map((pair) => {
292
+ const sides = historyScored.filter((item) => item.pair === pair)
293
+ return { pair, sides, pass: sides.every((item) => item.pass) }
294
+ })
295
+ const pairAccuracy = pairs.length === 0 ? 0 : pairs.filter((pair) => pair.pass).length / pairs.length
296
+
297
+ const groups = KINDS.map((kind) => {
298
+ const list = scored.filter((item) => item.kind === kind)
299
+ const negatives = list.filter((item) => item.kind === 'negative')
300
+ const answers = list.filter((item) => item.kind !== 'negative')
301
+ return {
302
+ kind,
303
+ total: list.length,
304
+ recall1: mean(answers.map((item) => item.recall1)),
305
+ recall5: mean(answers.map((item) => item.recall5)),
306
+ mrr: mean(answers.map((item) => item.reciprocalRank)),
307
+ temporalRule: kind === 'temporal' ? list.filter((item) => item.recall5 === 1 && item.demoteOk).length : null,
308
+ falsePositives: negatives.filter((item) => item.falsePositive).length,
309
+ /** Negatives that fell through to the recency fallback: correctly "nothing found". */
310
+ fallback: negatives.filter((item) => item.fallback).length,
311
+ negatives: negatives.length,
312
+ failed: list.filter((item) => !item.pass).length,
313
+ }
314
+ })
315
+
316
+ const answers = scored.filter((item) => item.kind !== 'negative')
317
+ const negatives = scored.filter((item) => item.kind === 'negative')
318
+
319
+ const overall = {
320
+ recall1: mean(answers.map((item) => item.recall1)),
321
+ recall5: mean(answers.map((item) => item.recall5)),
322
+ mrr: mean(answers.map((item) => item.reciprocalRank)),
323
+ falsePositives: negatives.filter((item) => item.falsePositive).length,
324
+ }
325
+
326
+ const recallPass = overall.recall5 >= GATE_RECALL5
327
+ const negativePass = overall.falsePositives <= GATE_NEGATIVE_FP
328
+ const historyPass = pairAccuracy >= GATE_PAIRS
329
+ const passed = recallPass && negativePass && historyPass
330
+
331
+ /* ---------------------------------------------------------------- report */
332
+
333
+ const out = []
334
+ out.push('memory-graph · retrieval eval')
335
+ out.push(`vault ${dir}`)
336
+ out.push(
337
+ `cards ${stats.cards.total} · kinds ${Object.entries(stats.cards.byKind).map(([k, n]) => `${k} ${n}`).join(', ')} · ` +
338
+ `active ${stats.cards.active} / superseded ${stats.cards.superseded} / retracted ${stats.cards.retracted}`,
339
+ )
340
+ out.push(
341
+ `entities ${stats.entities} · cross-project bridges ${bridges.length}: ${bridges.map((b) => `${b.slug}(${b.projects.length})`).join(', ')}`,
342
+ )
343
+ out.push(`retrieval limit ${LIMIT}, hops ${HOPS} · negative false-positive threshold score >= ${NEGATIVE_SCORE}`)
344
+ out.push('')
345
+ out.push('-- fixture and eval set --')
346
+ for (const item of results) {
347
+ out.push(` ${item.ok ? 'ok ' : 'FAIL'} ${item.name}${item.ok ? '' : `: ${item.message}`}`)
348
+ }
349
+ const fixtureOk = results.every((item) => item.ok)
350
+
351
+ out.push('')
352
+ out.push('-- score by question type --')
353
+ out.push(
354
+ table(
355
+ ['题型', 'n', 'recall@1', 'recall@5', 'MRR', '备注'],
356
+ [
357
+ ...groups.map((group) => [
358
+ group.kind,
359
+ String(group.total),
360
+ group.kind === 'negative' ? '-' : fixed(group.recall1),
361
+ group.kind === 'negative' ? '-' : fixed(group.recall5),
362
+ group.kind === 'negative' ? '-' : fixed(group.mrr),
363
+ group.kind === 'negative'
364
+ ? `误报 ${group.falsePositives}/${group.negatives} · 兜底 ${group.fallback}`
365
+ : group.kind === 'temporal'
366
+ ? `时序规则 ${group.temporalRule}/${group.total}`
367
+ : group.failed > 0
368
+ ? `未过 ${group.failed}`
369
+ : '',
370
+ ]),
371
+ [
372
+ '全部',
373
+ String(scored.length),
374
+ fixed(overall.recall1),
375
+ fixed(overall.recall5),
376
+ fixed(overall.mrr),
377
+ `负面误报 ${overall.falsePositives}/${negatives.length}`,
378
+ ],
379
+ ],
380
+ ),
381
+ )
382
+
383
+ out.push('')
384
+ out.push('-- per question --')
385
+ for (const item of scored) {
386
+ const rankText = item.ranks.length === 0 ? '' : ` ranks ${item.ranks.map((rank) => rank || 'missing').join(',')}`
387
+ const demote = item.kind === 'temporal' ? ` demote ${item.demoteOk ? 'ok' : 'VIOLATED'}` : ''
388
+ const marker = item.pass ? 'ok ' : 'MISS'
389
+ out.push(` ${marker} ${pad(item.id, 3)} ${pad(item.kind, 13)} ${item.query}${rankText}${demote}`)
390
+ }
391
+
392
+ const broken = [...scored, ...historyScored].filter((item) => !item.pass)
393
+ if (broken.length > 0) {
394
+ out.push('')
395
+ out.push('-- what a miss actually returned --')
396
+ for (const item of broken) {
397
+ out.push(` ${item.id} · ${item.query}`)
398
+ const expects = wanted.questions.find((q) => q.id === item.id)
399
+ out.push(` expect: ${(expects.expect ?? []).map((id) => `${id} ${byId.get(id).title}`).join(' | ') || '(nothing)'}`)
400
+ if (expects.rival) out.push(` rival: ${expects.rival.map((id) => `${id} ${byId.get(id).title}`).join(' | ')}`)
401
+ for (const [index, hit] of item.results.slice(0, 3).entries()) {
402
+ out.push(` #${index + 1} ${fixed(hit.score, 3)} ${hit.via.padEnd(16)} ${hit.card.id} ${hit.card.title}`)
403
+ }
404
+ if (item.results.length === 0) out.push(' (no results)')
405
+ }
406
+ }
407
+
408
+ out.push('')
409
+ out.push('-- negative questions (the vault never recorded these) --')
410
+ for (const item of negatives) {
411
+ const label = item.falsePositive ? 'FALSE POSITIVE' : item.fallback ? 'fallback only' : 'below threshold'
412
+ out.push(` ${pad(item.id, 3)} ${pad(fixed(item.topScore, 3), 5)} ${pad(label, 14)} ${item.query}`)
413
+ const top = item.results[0]
414
+ if (top) out.push(` top hit: ${top.card.id} ${top.card.title}`)
415
+ }
416
+ out.push('')
417
+ out.push(`-- history pairs (${pairs.length} 对 · 问历史 vs 问现状 · 单独门槛 成对准确率 >= ${GATE_PAIRS}) --`)
418
+ out.push(
419
+ table(
420
+ ['题号', 'side', '问题', '期望卡名次', '对侧卡名次', '判定'],
421
+ historyScored.map((item) => [
422
+ item.id,
423
+ item.side,
424
+ item.query,
425
+ item.ranks[0] ? String(item.ranks[0]) : 'missing',
426
+ item.rivalRanks[0] ? String(item.rivalRanks[0]) : '-',
427
+ item.pass ? 'ok' : 'MISS',
428
+ ]),
429
+ ),
430
+ )
431
+ for (const pair of pairs) {
432
+ out.push(
433
+ ` pair ${pair.pair} ${pair.pass ? 'ok ' : 'MISS'} ${pair.sides.map((side) => `${side.id}:${side.pass ? 'ok' : 'MISS'}`).join(' ')}`,
434
+ )
435
+ }
436
+ out.push(
437
+ ` 成对准确率 ${pairs.filter((pair) => pair.pass).length}/${pairs.length} = ${fixed(pairAccuracy)} (gate >= ${GATE_PAIRS}) ${historyPass ? 'PASS' : 'FAIL'}`,
438
+ )
439
+
440
+ out.push('')
441
+ out.push(`means are over the ${answers.length} answerable questions; the ${negatives.length} negative ones only count false positives`)
442
+
443
+ out.push('')
444
+ out.push('-- verdict --')
445
+ out.push(` recall@5 ${fixed(overall.recall5)} (gate >= ${GATE_RECALL5}) ${recallPass ? 'PASS' : 'FAIL'}`)
446
+ out.push(` negative ${overall.falsePositives} / ${negatives.length} 误报 (gate <= ${GATE_NEGATIVE_FP}) ${negativePass ? 'PASS' : 'FAIL'}`)
447
+ out.push(
448
+ ` history ${pairs.filter((pair) => pair.pass).length} / ${pairs.length} 对 ${fixed(pairAccuracy)} (gate >= ${GATE_PAIRS}) ${historyPass ? 'PASS' : 'FAIL'}`,
449
+ )
450
+ out.push(` ${passed ? 'PASS' : 'FAIL'}`)
451
+
452
+ console.log(out.join('\n'))
453
+
454
+ if (passed && fixtureOk) {
455
+ await fsp.rm(dir, { recursive: true, force: true })
456
+ process.exitCode = 0
457
+ } else {
458
+ console.log(`\nvault kept for inspection: ${dir}`)
459
+ process.exitCode = 1
460
+ }