dsh-memu-memory 1.2.0 → 1.2.2

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 (100) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +19 -8
  3. package/README.zh.md +12 -6
  4. package/docs/contributing.md +16 -3
  5. package/docs/env-and-commands.md +8 -0
  6. package/docs/host-api.md +3 -1
  7. package/docs/memory-semantics.md +21 -2
  8. package/docs/troubleshooting.md +42 -3
  9. package/lib/activity-log.js +22 -5
  10. package/lib/backup.js +24 -2
  11. package/lib/binding-diagnostics.js +3 -3
  12. package/lib/client.js +1610 -117
  13. package/lib/consolidation-worker.js +68 -17
  14. package/lib/curator-evidence.js +131 -0
  15. package/lib/curator-policy.js +81 -0
  16. package/lib/curator.js +1299 -83
  17. package/lib/cycle.js +3 -0
  18. package/lib/embedding-download.js +39 -4
  19. package/lib/engine.js +6 -3
  20. package/lib/global-graph.js +282 -0
  21. package/lib/graph.js +190 -14
  22. package/lib/inbound.js +19 -0
  23. package/lib/index.js +33 -1
  24. package/lib/inject.js +277 -17
  25. package/lib/lexicon.js +1115 -0
  26. package/lib/local-vector-service.js +8 -6
  27. package/lib/managed-runtime.js +33 -2
  28. package/lib/memory.js +585 -76
  29. package/lib/memu.js +2 -2
  30. package/lib/migrate.js +83 -2
  31. package/lib/private-links.js +587 -0
  32. package/lib/private.js +128 -7
  33. package/lib/prompts/global-curator.md +76 -19
  34. package/lib/prompts/private-consolidation.coding.md +4 -1
  35. package/lib/prompts/private-consolidation.md +3 -1
  36. package/lib/prompts/private-consolidation.mixed.md +3 -1
  37. package/lib/prompts/private-consolidation.rp.md +3 -1
  38. package/lib/prompts.js +1 -1
  39. package/lib/rpc.js +622 -13
  40. package/lib/state.js +4 -1
  41. package/lib/text-retrieval.js +661 -0
  42. package/lib/text-similarity.js +124 -0
  43. package/lib/tools.js +230 -16
  44. package/lib/vector-migration.js +28 -3
  45. package/lib/vector-profile.js +24 -0
  46. package/lib/webui/standalone.css +17 -0
  47. package/lib/webui/standalone.js +2 -0
  48. package/package.json +3 -2
  49. package/scripts/acceptance-local.mjs +8 -5
  50. package/scripts/curator-pair-calibrate.mjs +259 -0
  51. package/scripts/lexicon-calibrate.mjs +230 -0
  52. package/scripts/lexicon-dedupe.mjs +318 -0
  53. package/scripts/replay.mjs +573 -0
  54. package/tests/absorb-review.test.mjs +353 -0
  55. package/tests/appendix-b.test.mjs +6 -2
  56. package/tests/appendix-b2.test.mjs +2 -2
  57. package/tests/client-export.test.mjs +520 -0
  58. package/tests/client-render.test.mjs +19 -3
  59. package/tests/consolidation-credential.test.mjs +16 -5
  60. package/tests/consolidation-feed.test.mjs +96 -0
  61. package/tests/consolidation-fork.test.mjs +144 -10
  62. package/tests/consolidation-snapshot.test.mjs +82 -4
  63. package/tests/curator-absorb.test.mjs +443 -0
  64. package/tests/curator-calibrate.test.mjs +173 -0
  65. package/tests/curator-clusters.test.mjs +281 -0
  66. package/tests/curator-commit.test.mjs +324 -0
  67. package/tests/curator-evidence.test.mjs +102 -0
  68. package/tests/curator-link.test.mjs +316 -0
  69. package/tests/curator-pairing.test.mjs +252 -0
  70. package/tests/curator-probe.test.mjs +15 -6
  71. package/tests/curator-review-mode.test.mjs +314 -0
  72. package/tests/disclosure.test.mjs +2 -1
  73. package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
  74. package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
  75. package/tests/fixtures/lexicon-stub.mjs +284 -0
  76. package/tests/graph-cascade.test.mjs +223 -0
  77. package/tests/graph.test.mjs +113 -0
  78. package/tests/help-page.test.mjs +415 -0
  79. package/tests/hybrid-recall.test.mjs +32 -2
  80. package/tests/inbound.test.mjs +6 -3
  81. package/tests/inject-guide.test.mjs +28 -0
  82. package/tests/inject.test.mjs +157 -5
  83. package/tests/lexicon-calibrate.test.mjs +48 -0
  84. package/tests/lexicon-dedupe.test.mjs +171 -0
  85. package/tests/lexicon-frequency.test.mjs +179 -0
  86. package/tests/lexicon-inject.test.mjs +410 -0
  87. package/tests/lexicon-perf.test.mjs +436 -0
  88. package/tests/lexicon-ui.test.mjs +547 -0
  89. package/tests/lexicon.test.mjs +922 -0
  90. package/tests/managed-memory.test.mjs +55 -0
  91. package/tests/managed-runtime.test.mjs +32 -0
  92. package/tests/private-links.test.mjs +445 -0
  93. package/tests/prompts.test.mjs +35 -0
  94. package/tests/recall-dedup.test.mjs +446 -0
  95. package/tests/sidebar-beat.test.mjs +5 -1
  96. package/tests/tool-schema.test.mjs +1 -1
  97. package/tests/tools.test.mjs +112 -3
  98. package/tests/vector-migration.test.mjs +58 -1
  99. package/tests/webui-auth.test.mjs +348 -2
  100. package/tests/webui.test.mjs +191 -2
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env node
2
+ // P0 校准(spec §4.13):labeled pair fixture → pair features/score/decision →
3
+ // 阈值扫描 + 混淆矩阵 + 候选召回。数据是合成草稿(synthetic-draft-not-gold),
4
+ // 报告如实标注,不宣称生产金标。
5
+ //
6
+ // 用法:
7
+ // node scripts/curator-pair-calibrate.mjs \
8
+ // --fixture tests/fixtures/curator-pairs/labeled.v1.jsonl \
9
+ // [--out artifacts/curator-pairs.json] [--json] [--from 0.50] [--to 0.95] [--step 0.01]
10
+ //
11
+ // 校准门(§4.13):
12
+ // absorb @0.82 precision ≥0.95;link @0.55 precision ≥0.85;
13
+ // none 误报率 @0.55 ≤0.10;absorb 候选 recall@20 ≥0.95。
14
+ // 未达标先调权重/分词,不直接降阈值;吸收误合并比漏合并更贵。
15
+
16
+ import fs from 'node:fs'
17
+ import path from 'node:path'
18
+ import { pathToFileURL } from 'node:url'
19
+ import { buildCorpus, pairDecision, pairFeatures, pairScore, rankPairCandidates } from '../lib/text-retrieval.js'
20
+
21
+ export const MARK = 'synthetic-draft-not-gold'
22
+ export const LABELS = ['same-fact', 'related', 'none']
23
+ const POSITIVE_ABSORB = ['same-fact']
24
+ const POSITIVE_LINK = ['same-fact', 'related']
25
+
26
+ function parseArgs(argv) {
27
+ const args = { fixture: 'tests/fixtures/curator-pairs/labeled.v1.jsonl', out: null, json: false, from: 0.5, to: 0.95, step: 0.01 }
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const arg = argv[i]
30
+ if (arg === '--fixture') args.fixture = argv[++i]
31
+ else if (arg === '--out') args.out = argv[++i]
32
+ else if (arg === '--json') args.json = true
33
+ else if (arg === '--from') args.from = Number(argv[++i])
34
+ else if (arg === '--to') args.to = Number(argv[++i])
35
+ else if (arg === '--step') args.step = Number(argv[++i])
36
+ else if (arg === '--help' || arg === '-h') args.help = true
37
+ }
38
+ return args
39
+ }
40
+
41
+ export function loadFixture(file) {
42
+ const raw = fs.readFileSync(file, 'utf8')
43
+ const pairs = []
44
+ for (const [index, line] of raw.split(/\r?\n/).entries()) {
45
+ const text = line.trim()
46
+ if (!text || text.startsWith('//')) continue
47
+ const pair = JSON.parse(text)
48
+ pair.line = index + 1
49
+ pairs.push(pair)
50
+ }
51
+ return pairs
52
+ }
53
+
54
+ /** features/score/decision for every pair — the single scoring pass all metrics share. */
55
+ export function scorePairs(pairs) {
56
+ return pairs.map((pair) => {
57
+ const features = pairFeatures(pair.a, pair.b)
58
+ const score = pairScore(features)
59
+ const decision = pairDecision(pair.a, pair.b, features)
60
+ return { id: pair.id, label: pair.label, line: pair.line || null, tags: pair.tags || [], score, decision, features }
61
+ })
62
+ }
63
+
64
+ function round4(value) {
65
+ return Math.round(value * 10000) / 10000
66
+ }
67
+
68
+ /** One threshold row: precision/recall/F1 of `score >= threshold` against positiveLabels. */
69
+ export function sweepAt(results, positiveLabels, threshold) {
70
+ let tp = 0
71
+ let fp = 0
72
+ let fn = 0
73
+ for (const r of results) {
74
+ const positive = positiveLabels.includes(r.label)
75
+ const predicted = r.score >= threshold
76
+ if (predicted && positive) tp += 1
77
+ else if (predicted && !positive) fp += 1
78
+ else if (!predicted && positive) fn += 1
79
+ }
80
+ const precision = tp + fp > 0 ? tp / (tp + fp) : null
81
+ const recall = tp + fn > 0 ? tp / (tp + fn) : null
82
+ const f1 = precision != null && recall != null && precision + recall > 0 ? 2 * precision * recall / (precision + recall) : null
83
+ return { threshold, tp, fp, fn, precision: precision == null ? null : round4(precision), recall: recall == null ? null : round4(recall), f1: f1 == null ? null : round4(f1) }
84
+ }
85
+
86
+ export function sweepThresholds(results, positiveLabels, { from = 0.5, to = 0.95, step = 0.01 } = {}) {
87
+ const rows = []
88
+ for (let t = from; t <= to + 1e-9; t += step) {
89
+ rows.push(sweepAt(results, positiveLabels, Math.round(t * 100) / 100))
90
+ }
91
+ return rows
92
+ }
93
+
94
+ /** label × decision matrix — where the decision bands actually land. */
95
+ export function confusionMatrix(results) {
96
+ const decisions = [...new Set(results.map((r) => r.decision))].sort()
97
+ const matrix = {}
98
+ for (const label of LABELS) {
99
+ matrix[label] = {}
100
+ for (const decision of decisions) matrix[label][decision] = 0
101
+ }
102
+ for (const r of results) {
103
+ if (!matrix[r.label]) matrix[r.label] = {}
104
+ matrix[r.label][r.decision] = (matrix[r.label][r.decision] || 0) + 1
105
+ }
106
+ return { decisions, matrix }
107
+ }
108
+
109
+ /**
110
+ * §4.13 candidate recall@20: every same-fact pair's a-side goes through
111
+ * rankPairCandidates against the corpus of all unique fixture entries; the
112
+ * b-side must appear in the top `limit`. This is the recall the curator's
113
+ * duplicate/conflict gate actually sees.
114
+ */
115
+ export function candidateRecall(pairs, { limit = 20 } = {}) {
116
+ const unique = new Map()
117
+ for (const pair of pairs) {
118
+ for (const side of ['a', 'b']) {
119
+ const entry = pair[side]
120
+ if (entry && entry.id != null && !unique.has(entry.id)) unique.set(entry.id, entry)
121
+ }
122
+ }
123
+ // fixture entries carry id/title/content/track/domain directly — buildCorpus's contract
124
+ const corpus = buildCorpus([...unique.values()])
125
+ let total = 0
126
+ let hit = 0
127
+ const misses = []
128
+ for (const pair of pairs) {
129
+ if (pair.label !== 'same-fact') continue
130
+ total += 1
131
+ const found = rankPairCandidates({ corpus, proposal: { ...pair.a, id: pair.a.id }, limit })
132
+ if (found.some((c) => c.id === pair.b.id)) hit += 1
133
+ else misses.push({ id: pair.id, expected: pair.b.id, best: found[0] ? { id: found[0].id, score: round4(found[0].score) } : null })
134
+ }
135
+ return { limit, total, hit, recall: total ? round4(hit / total) : 1, misses }
136
+ }
137
+
138
+ /** §4.13 gates — reported as data, never enforced as a CI gate (spec §8.2). */
139
+ export function evaluateGates({ absorbAt082, linkAt055, noneAt055, recall20 }) {
140
+ return [
141
+ {
142
+ name: 'absorb precision @0.82',
143
+ metric: absorbAt082.precision,
144
+ floor: 0.95,
145
+ pass: absorbAt082.precision != null && absorbAt082.precision >= 0.95,
146
+ },
147
+ {
148
+ name: 'link precision @0.55',
149
+ metric: linkAt055.precision,
150
+ floor: 0.85,
151
+ pass: linkAt055.precision != null && linkAt055.precision >= 0.85,
152
+ },
153
+ {
154
+ name: 'none false-positive rate @0.55',
155
+ metric: noneAt055.rate,
156
+ ceiling: 0.1,
157
+ pass: noneAt055.rate != null && noneAt055.rate <= 0.10,
158
+ },
159
+ {
160
+ name: 'absorb candidate recall@20',
161
+ metric: recall20.recall,
162
+ floor: 0.95,
163
+ pass: recall20.recall != null && recall20.recall >= 0.95,
164
+ },
165
+ ]
166
+ }
167
+
168
+ export function buildReport(pairs, options = {}) {
169
+ const results = scorePairs(pairs)
170
+ const absorbAt082 = sweepAt(results, POSITIVE_ABSORB, 0.82)
171
+ const linkAt055 = sweepAt(results, POSITIVE_LINK, 0.55)
172
+ const noneResults = results.filter((r) => r.label === 'none')
173
+ const noneFlagged = noneResults.filter((r) => r.score >= 0.55).length
174
+ const noneAt055 = { total: noneResults.length, flagged: noneFlagged, rate: noneResults.length ? round4(noneFlagged / noneResults.length) : 0 }
175
+ const recall20 = candidateRecall(pairs, { limit: options.recallLimit || 20 })
176
+ const gates = evaluateGates({ absorbAt082, linkAt055, noneAt055, recall20 })
177
+ return {
178
+ mark: MARK,
179
+ note: 'synthetic draft fixture for threshold calibration; NOT a production gold set',
180
+ counts: {
181
+ pairs: pairs.length,
182
+ 'same-fact': results.filter((r) => r.label === 'same-fact').length,
183
+ related: results.filter((r) => r.label === 'related').length,
184
+ none: noneResults.length,
185
+ },
186
+ gates,
187
+ absorb: { ...absorbAt082, positives: POSITIVE_ABSORB },
188
+ link: { ...linkAt055, positives: POSITIVE_LINK },
189
+ noneFalsePositive: noneAt055,
190
+ candidateRecall: { limit: recall20.limit, total: recall20.total, hit: recall20.hit, recall: recall20.recall, misses: recall20.misses },
191
+ sweep: {
192
+ absorb: sweepThresholds(results, POSITIVE_ABSORB, options),
193
+ link: sweepThresholds(results, POSITIVE_LINK, options),
194
+ },
195
+ confusion: confusionMatrix(results),
196
+ pairs: results.map((r) => ({ id: r.id, label: r.label, score: round4(r.score), decision: r.decision, tags: r.tags })),
197
+ }
198
+ }
199
+
200
+ function printReport(report) {
201
+ const line = '─'.repeat(72)
202
+ console.log(line)
203
+ console.log(`curator pair calibration [${MARK}] — ${report.counts.pairs} pairs (${report.counts['same-fact']}/same-fact, ${report.counts.related}/related, ${report.counts.none}/none)`)
204
+ console.log(line)
205
+ console.log('gates (spec §4.13):')
206
+ for (const gate of report.gates) {
207
+ const bar = gate.pass ? 'PASS' : 'FAIL'
208
+ const bound = gate.floor != null ? `floor ${gate.floor}` : `ceiling ${gate.ceiling}`
209
+ console.log(` [${bar}] ${gate.name}: ${gate.metric} (${bound})`)
210
+ }
211
+ console.log(`absorb @0.82: precision ${report.absorb.precision} recall ${report.absorb.recall} f1 ${report.absorb.f1} (tp ${report.absorb.tp} fp ${report.absorb.fp} fn ${report.absorb.fn})`)
212
+ console.log(`link @0.55: precision ${report.link.precision} recall ${report.link.recall} f1 ${report.link.f1} (tp ${report.link.tp} fp ${report.link.fp} fn ${report.link.fn})`)
213
+ console.log(`none @0.55: false positives ${report.noneFalsePositive.flagged}/${report.noneFalsePositive.total} (${report.noneFalsePositive.rate})`)
214
+ console.log(`candidate recall@${report.candidateRecall.limit}: ${report.candidateRecall.hit}/${report.candidateRecall.total} (${report.candidateRecall.recall})`)
215
+ if (report.candidateRecall.misses.length) {
216
+ for (const miss of report.candidateRecall.misses.slice(0, 10)) {
217
+ console.log(` miss ${miss.id}: expected ${miss.expected}, best ${miss.best ? `${miss.best.id} @${miss.best.score}` : '(none)'}`)
218
+ }
219
+ }
220
+ console.log('confusion (label × decision):')
221
+ for (const [label, row] of Object.entries(report.confusion.matrix)) {
222
+ console.log(` ${label.padEnd(10)} ${JSON.stringify(row)}`)
223
+ }
224
+ const worst = [...report.pairs]
225
+ .filter((p) => (p.label === 'same-fact' && p.score < 0.82) || (p.label === 'related' && !(p.score >= 0.55 && p.score < 0.82)) || (p.label === 'none' && p.score >= 0.55))
226
+ .slice(0, 30)
227
+ if (worst.length) {
228
+ console.log('band outliers (same-fact <0.82 | related outside [0.55,0.82) | none ≥0.55):')
229
+ for (const p of worst) console.log(` ${p.id} [${p.label}] score ${p.score} decision ${p.decision} tags [${p.tags.join(',')}]`)
230
+ }
231
+ console.log(line)
232
+ }
233
+
234
+ async function main() {
235
+ const args = parseArgs(process.argv.slice(2))
236
+ if (args.help) {
237
+ console.log('usage: node scripts/curator-pair-calibrate.mjs --fixture <jsonl> [--out report.json] [--json] [--from 0.5] [--to 0.95] [--step 0.01]')
238
+ return
239
+ }
240
+ const fixturePath = path.resolve(args.fixture)
241
+ const pairs = loadFixture(fixturePath)
242
+ const report = buildReport(pairs, { from: args.from, to: args.to, step: args.step })
243
+ report.fixture = args.fixture
244
+ if (args.json) console.log(JSON.stringify(report, null, 2))
245
+ else printReport(report)
246
+ if (args.out) {
247
+ const outPath = path.resolve(args.out)
248
+ fs.mkdirSync(path.dirname(outPath), { recursive: true })
249
+ fs.writeFileSync(outPath, JSON.stringify(report, null, 2))
250
+ if (!args.json) console.log(`report written to ${outPath}`)
251
+ }
252
+ process.exitCode = report.gates.every((g) => g.pass) ? 0 : 1
253
+ }
254
+
255
+ // Direct-run detection that works on Windows paths too (no URL juggling).
256
+ const invoked = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ''
257
+ if (invoked && invoked === import.meta.url) {
258
+ await main()
259
+ }
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env node
2
+ // scripts/lexicon-calibrate.mjs — Dice 阈值标定脚手架(docs/lexicon-design.md §10 P2)
3
+ //
4
+ // 内置一组人工标注样本(正例对 ≥15:同词变体/别名命中/例句命中;负例对 ≥15:无关词/
5
+ // 同主题不同词/形近词),对每对跑 matchLexiconHits 的完整流水线(独立临时库,互不干扰),
6
+ // 输出每对的 dice 值、在候选阈值下的判定 vs 标注,以及各阈值的 precision/recall/FP/FN。
7
+ //
8
+ // 本脚本只出报告,**绝不自动改 LEXICON_DICE_THRESHOLD**——阈值调整是人看了报告之后的决定。
9
+ // 指标只陈述「这套样本上的表现」,不外推为线上分布。
10
+ //
11
+ // 用法:node scripts/lexicon-calibrate.mjs [--threshold=0.55,0.6,0.65,0.7,0.75]
12
+ // --threshold 接受一个或多个(逗号分隔)0..1 之间的值;缺省扫 0.55..0.75 步长 0.05。
13
+ import fs from 'node:fs'
14
+ import os from 'node:os'
15
+ import path from 'node:path'
16
+
17
+ // 标定在自己的一次性 DSH_MEMU_HOME 里跑:绝不读写真实数据目录。
18
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-lexicon-calibrate-'))
19
+ process.env.DSH_MEMU_HOME = tempHome
20
+
21
+ const DEFAULT_THRESHOLDS = [0.55, 0.6, 0.65, 0.7, 0.75]
22
+
23
+ /**
24
+ * 标注样本。dice 值是设计出来的已知值(2×共享 bigram ÷ 两侧 bigram 数),注释给出
25
+ * 预期分数,跑出来的表应当能对上;对不上时说明匹配算法变了,报告会直接显示。
26
+ * 每对 = { label, note, term: {term, aliases}, text };pair 之间用独立临时库隔离。
27
+ */
28
+ const POSITIVE = [
29
+ { note: '同词变体(末字异写,预期 dice≈0.667)', term: { term: '糖刀文学' }, text: '这段糖刀文雪啊' },
30
+ { note: '同词变体(刀锋样本:预期 dice≈0.60,恰在候选阈值上)', term: { term: '贴贴抱抱举起' }, text: '贴贴抱抱捏捏' },
31
+ { note: '同词变体(预期 dice≈0.75)', term: { term: '雾夜航行记' }, text: '今晚雾夜航行迟了' },
32
+ { note: '同词变体(预期 dice≈0.80)', term: { term: '霓虹深海怪谈' }, text: '传言是霓虹深海怪人' },
33
+ { note: '同词变体·拉丁(预期 dice≈0.667)', term: { term: 'teatime' }, text: 'teatyme with friends' },
34
+ { note: '同词变体(预期 dice≈0.667)', term: { term: '破防时刻' }, text: '这段剧情让我破防时该' },
35
+ { note: '同词变体(预期 dice≈0.667)', term: { term: '塌房现场' }, text: '这里简直塌房现成' },
36
+ { note: '同词变体(预期 dice≈0.80)', term: { term: '贴贴文学鉴赏' }, text: '简直是贴贴文学鉴尝' },
37
+ { note: '别名命中(exact-alias,score=1)', term: { term: '糖刀', aliases: ['糖里藏刀'] }, text: '这不就是糖里藏刀吗' },
38
+ { note: '别名命中(exact-alias,score=1)', term: { term: 'moonfall', aliases: ['坠月'] }, text: '听说昨晚有坠月异象' },
39
+ { note: '别名命中(短 alias 的包含,score=1)', term: { term: '咖啡上头', aliases: ['上头'] }, text: '喝三杯就上头' },
40
+ { note: '例句命中(文本包含词面,exact-term,score=1)', term: { term: '霓虹深海' }, text: '今晚的街道是典型的霓虹深海氛围' },
41
+ { note: '例句命中(exact-term,score=1)', term: { term: '回声体' }, text: '他说话一股回声体的味道' },
42
+ { note: '整句等值(exact-term,score=1)', term: { term: '茶里茶气' }, text: '茶里茶气' },
43
+ { note: '例句命中(exact-term,score=1)', term: { term: '月引仪式' }, text: '按照月引仪式的流程走完' },
44
+ { note: '前缀截断(短文本 vs 长词条,预期 dice≈0.667;是否算命中本身值得人review)', term: { term: '回声体' }, text: '回声' },
45
+ ]
46
+
47
+ const NEGATIVE = [
48
+ { note: '无关词', term: { term: '糖刀文学' }, text: '今天天气不错' },
49
+ { note: '无关词·拉丁', term: { term: 'teatime' }, text: 'hello world' },
50
+ { note: '同主题不同词(夜航 ≠ 雾夜航行)', term: { term: '雾夜航行' }, text: '夜航模式开启' },
51
+ { note: '同主题不同词', term: { term: '破防时刻' }, text: '破大防了' },
52
+ { note: '同主题不同词', term: { term: '塌房现场' }, text: '文塌了' },
53
+ { note: '同主题不同词', term: { term: '月引仪式' }, text: '霜露降在桥边' },
54
+ { note: '形近词(预期 dice≈0.333)', term: { term: '贴贴抱抱' }, text: '贴贴报抱' },
55
+ { note: '形近词(预期 dice≈0.333)', term: { term: '糖刀文学' }, text: '糖刀桌子' },
56
+ { note: '形近词(预期 dice≈0.2)', term: { term: '贴贴抱抱举起' }, text: '贴贴报抱拍拍' },
57
+ { note: '形近词(共享单字但无共享 bigram,dice=0)', term: { term: '红牌' }, text: '红树牌楼' },
58
+ { note: '形近词·拉丁(预期 dice≈0.43)', term: { term: 'moonfall' }, text: 'moonrise' },
59
+ { note: '形近词·拉丁(预期 dice≈0.31)', term: { term: 'teatime' }, text: 'tea party' },
60
+ { note: '同主题形近(候选入桶但预期 dice≈0.333)', term: { term: '咖啡上头' }, text: '咖啡因过量' },
61
+ { note: '形近词·三字(预期 dice=0.5,刀锋负例)', term: { term: '破大防' }, text: '破大房' },
62
+ { note: '词面前缀出现在句中但整词不在(预期 dice≈0.25)', term: { term: '贴贴抱抱举起' }, text: '我们贴贴猫吧' },
63
+ { note: '同主题改写(无词面重叠)', term: { term: '糖里藏刀' }, text: '他藏着刀' },
64
+ ]
65
+
66
+ function normalizeLexeme(text) {
67
+ return String(text ?? '').normalize('NFKC').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '')
68
+ }
69
+
70
+ function buildEntry(pair, termId) {
71
+ const term = pair.term.term
72
+ const aliases = Array.isArray(pair.term.aliases) ? pair.term.aliases : []
73
+ return {
74
+ term,
75
+ normalizedTerm: normalizeLexeme(term),
76
+ kind: 'term',
77
+ meaning: '标定样本词条',
78
+ usage: '',
79
+ aliases,
80
+ normalizedAliases: aliases.map(normalizeLexeme).filter(Boolean),
81
+ examples: [],
82
+ context: {},
83
+ visibility: 'shared',
84
+ sensitivity: 'normal',
85
+ strength: 0.6,
86
+ confidence: 0.8,
87
+ status: 'active',
88
+ source: 'user',
89
+ evidence: [],
90
+ fingerprint: `fp-${termId}`,
91
+ createdAt: '2026-01-01T00:00:00.000Z',
92
+ updatedAt: '2026-01-01T00:00:00.000Z',
93
+ hitCounts: { candidate: 0, injection: 0, query: 0 },
94
+ lastInjectedAt: null,
95
+ }
96
+ }
97
+
98
+ function parseThresholds(args) {
99
+ const out = []
100
+ for (let i = 0; i < args.length; i++) {
101
+ const arg = args[i]
102
+ let raw = null
103
+ if (arg.startsWith('--threshold=')) raw = arg.slice('--threshold='.length)
104
+ else if (arg === '--threshold') raw = args[++i] || ''
105
+ if (raw === null) continue
106
+ for (const piece of String(raw).split(',')) {
107
+ const value = Number(piece)
108
+ if (Number.isFinite(value) && value > 0 && value < 1) out.push(value)
109
+ }
110
+ }
111
+ if (!out.length) return [...DEFAULT_THRESHOLDS]
112
+ return [...new Set(out)].sort((a, b) => a - b)
113
+ }
114
+
115
+ async function run() {
116
+ const { dataDir, writeJson } = await import('../lib/paths.js')
117
+ const lexicon = await import('../lib/lexicon.js')
118
+ const { bigrams, dice } = await import('../lib/text-similarity.js')
119
+ const thresholds = parseThresholds(process.argv.slice(2))
120
+ const primary = thresholds.includes(0.6) ? 0.6 : thresholds[0]
121
+
122
+ // matchLexiconHits 只会返回 score ≥ LEXICON_DICE_THRESHOLD 的 dice 命中,低于阈值的
123
+ // 候选分数被丢弃——而标定恰恰要看那些"差一点"的值。这里按 lib/diceFallback 同一
124
+ // 滑窗口径(词面前 2 字定位、等长窗口、取最大;候选资格也同 lib:词面前缀必须落
125
+ // 在文本前 16 个互异 2 字窗口内)重算**原始 dice**,阈值判定由报告按各候选阈值给出。
126
+ function rawDiceScore(entry, text) {
127
+ const normalized = normalizeLexeme(text)
128
+ const exact = normalized === entry.normalizedTerm
129
+ || (entry.normalizedAliases || []).includes(normalized)
130
+ || [entry.normalizedTerm, ...(entry.normalizedAliases || [])].some(surface =>
131
+ surface.length >= 2 && normalized.includes(surface))
132
+ if (exact) return { score: 1, exact: true }
133
+ const surfaces = [entry.normalizedTerm, ...(entry.normalizedAliases || [])]
134
+ // 候选资格:文本前 16 个互异 2 字窗口里必须出现某词面的前 2 字(LEXICON_MAX_BUCKETS)。
135
+ const windows = []
136
+ const seen = new Set()
137
+ for (let i = 0; i + 2 <= normalized.length && windows.length < 16; i++) {
138
+ const key = normalized.slice(i, i + 2)
139
+ if (seen.has(key)) continue
140
+ seen.add(key)
141
+ windows.push(key)
142
+ }
143
+ const surfaceList = surfaces.filter(surface => surface && surface.length >= 2)
144
+ if (!surfaceList.length) return { score: 0, exact: false }
145
+ if (!windows.some(key => surfaceList.some(surface => surface.slice(0, 2) === key))) {
146
+ return { score: 0, exact: false }
147
+ }
148
+ let best = 0
149
+ for (const surface of surfaceList) {
150
+ const key = surface.slice(0, 2)
151
+ let pos = normalized.indexOf(key)
152
+ while (pos >= 0) {
153
+ const window = normalized.slice(pos, pos + surface.length)
154
+ const score = dice(bigrams(window), bigrams(surface))
155
+ if (score > best) best = score
156
+ if (best >= 1) break
157
+ pos = normalized.indexOf(key, pos + 1)
158
+ }
159
+ if (best >= 1) break
160
+ }
161
+ return { score: best, exact: false }
162
+ }
163
+
164
+ const pairs = [
165
+ ...POSITIVE.map(pair => ({ ...pair, expect: 'pos' })),
166
+ ...NEGATIVE.map(pair => ({ ...pair, expect: 'neg' })),
167
+ ]
168
+ const results = []
169
+ for (let i = 0; i < pairs.length; i++) {
170
+ const pair = pairs[i]
171
+ const termId = 'lex-cal-1'
172
+ const libraryId = `calibrate-${String(i + 1).padStart(2, '0')}`
173
+ const file = path.join(dataDir(), 'lexicon', `${libraryId}.json`)
174
+ writeJson(file, { version: 1, revision: 1, libraryId, terms: { [termId]: buildEntry(pair, termId) }, rejected: {} })
175
+ const hits = lexicon.matchLexiconHits(libraryId, pair.text, {})
176
+ const hit = hits.find(item => item.termId === termId) || null
177
+ const raw = rawDiceScore(buildEntry(pair, termId), pair.text)
178
+ results.push({
179
+ id: `${pair.expect === 'pos' ? 'P' : 'N'}${String((pair.expect === 'pos' ? i + 1 : i + 1 - POSITIVE.length)).padStart(2, '0')}`,
180
+ expect: pair.expect,
181
+ note: pair.note,
182
+ term: pair.term.term,
183
+ text: pair.text,
184
+ score: raw.score,
185
+ kindOfHit: hit ? hit.kindOfHit : 'miss',
186
+ })
187
+ }
188
+
189
+ console.log('lexicon-calibrate — Dice 阈值标定报告(只出报告,不自动改 LEXICON_DICE_THRESHOLD)')
190
+ console.log(`样本:正例 ${POSITIVE.length} 对,负例 ${NEGATIVE.length} 对;每对独立临时库。dice 列是与 lib 同口径滑窗重算的原始值(lib 会丢弃阈值以下的分数,标定需要看见它们)。`)
191
+ console.log('')
192
+ console.log(`pair 标注 判定@${primary.toFixed(2)} dice lib@0.60 term ← 文本 — 说明`)
193
+ for (const row of results) {
194
+ const predicted = row.score >= primary ? 'pos' : 'neg'
195
+ const verdict = row.expect === 'pos'
196
+ ? (predicted === 'pos' ? 'TP' : 'FN')
197
+ : (predicted === 'pos' ? 'FP' : 'TN')
198
+ console.log(`${row.id} ${row.expect} ${verdict} ${row.score.toFixed(3)} ${row.kindOfHit.padEnd(12)} ${row.term} ← ${row.text} — ${row.note}`)
199
+ }
200
+ console.log('')
201
+ console.log('汇总(各候选阈值):')
202
+ console.log('threshold TP FP TN FN precision recall')
203
+ for (const threshold of thresholds) {
204
+ let tp = 0, fp = 0, tn = 0, fn = 0
205
+ for (const row of results) {
206
+ const predicted = row.score >= threshold ? 'pos' : 'neg'
207
+ if (row.expect === 'pos' && predicted === 'pos') tp += 1
208
+ else if (row.expect === 'neg' && predicted === 'pos') fp += 1
209
+ else if (row.expect === 'neg' && predicted === 'neg') tn += 1
210
+ else fn += 1
211
+ }
212
+ const precision = tp + fp ? tp / (tp + fp) : 0
213
+ const recall = tp + fn ? tp / (tp + fn) : 0
214
+ console.log(`${threshold.toFixed(2)} ${String(tp).padStart(3)} ${String(fp).padStart(3)} ${String(tn).padStart(3)} ${String(fn).padStart(3)} ${precision.toFixed(3)} ${recall.toFixed(3)}`)
215
+ }
216
+ const at = results.filter(row => row.expect === 'pos' && row.score >= primary).length
217
+ const fpAt = results.filter(row => row.expect === 'neg' && row.score >= primary).length
218
+ const fnAt = POSITIVE.length - at
219
+ const tnAt = NEGATIVE.length - fpAt
220
+ const precisionAt = at + fpAt ? at / (at + fpAt) : 0
221
+ const recallAt = at + fnAt ? at / (at + fnAt) : 0
222
+ console.log('')
223
+ console.log(`当前 ${primary.toFixed(2)} 下的指标:precision=${precisionAt.toFixed(3)} recall=${recallAt.toFixed(3)} FP=${fpAt} FN=${fnAt} TN=${tnAt}(样本 ${results.length} 对;只代表本标注集,不外推为线上分布;调整阈值前先看刀锋样本 P02/N14)`)
224
+ }
225
+
226
+ try {
227
+ await run()
228
+ } finally {
229
+ try { fs.rmSync(tempHome, { recursive: true, force: true }) } catch {}
230
+ }