dsh-memory-gate 0.3.2 → 0.5.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 (49) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +8 -1
  3. package/backtest/capture.mjs +62 -0
  4. package/backtest/measure.mjs +43 -0
  5. package/backtest/measure.test.mjs +43 -0
  6. package/backtest/observe.mjs +56 -0
  7. package/backtest/result.mjs +97 -0
  8. package/backtest/run.mjs +212 -0
  9. package/backtest/schema-v0.json +34 -0
  10. package/backtest/trajectory.mjs +74 -0
  11. package/backtest/trajectory.test.mjs +37 -0
  12. package/fixtures/synthetic/scenario-01-pref-zh-concise.json +11 -0
  13. package/fixtures/synthetic/scenario-02-constraint-no-creds.json +11 -0
  14. package/fixtures/synthetic/scenario-03-fact-project-path.json +11 -0
  15. package/fixtures/synthetic/scenario-04-quarantine-warning.json +11 -0
  16. package/fixtures/synthetic/scenario-05-pollution-irrelevant.json +11 -0
  17. package/fixtures/synthetic/scenario-06-procedure-deploy.json +11 -0
  18. package/fixtures/synthetic/scenario-07-heuristic-verify.json +11 -0
  19. package/fixtures/synthetic/scenario-08-warning-risk-verify.json +11 -0
  20. package/fixtures/synthetic/scenario-09-weak-verify-port.json +11 -0
  21. package/fixtures/synthetic/scenario-10-en-preference.json +11 -0
  22. package/fixtures/synthetic/scenario-11-traditional-simplified.json +11 -0
  23. package/fixtures/synthetic/scenario-12-warning-weak-verify.json +12 -0
  24. package/fixtures/synthetic/scenario-13-expired-ignore.json +11 -0
  25. package/fixtures/synthetic/scenario-14-paraphrase-brief.json +11 -0
  26. package/fixtures/synthetic/scenario-15-paraphrase-refine.json +11 -0
  27. package/fixtures/synthetic/scenario-16-en-synonym.json +11 -0
  28. package/fixtures/synthetic/scenario-17-pollution-en-zh.json +11 -0
  29. package/fixtures/synthetic/scenario-18-scope-isolation.json +11 -0
  30. package/fixtures/synthetic/scenario-19-weak-verify-deploy.json +11 -0
  31. package/fixtures/synthetic/scenario-20-weak-verify-test.json +11 -0
  32. package/fixtures/synthetic/scenario-21-constraint-safety.json +11 -0
  33. package/fixtures/synthetic/scenario-22-fact-tool.json +11 -0
  34. package/fixtures/synthetic/scenario-23-procedure-multi-step.json +11 -0
  35. package/fixtures/synthetic/scenario-24-constraint-negative-zh.json +11 -0
  36. package/fixtures/synthetic/scenario-25-traditional-constraint.json +11 -0
  37. package/fixtures/synthetic/scenario-26-empty-store.json +9 -0
  38. package/fixtures/synthetic/scenario-27-multi-claim-select.json +12 -0
  39. package/fixtures/synthetic/scenario-28-warning-heuristic-verify.json +11 -0
  40. package/fixtures/synthetic/scenario-29-constraint-heuristic-verify.json +11 -0
  41. package/fixtures/synthetic/scenario-30-pollution-topic-mismatch.json +11 -0
  42. package/fixtures/synthetic/session-sample.jsonl +6 -0
  43. package/lib/authority.d.ts.map +1 -1
  44. package/lib/authority.js +12 -2
  45. package/lib/authority.js.map +1 -1
  46. package/lib/service.d.ts.map +1 -1
  47. package/lib/service.js +4 -5
  48. package/lib/service.js.map +1 -1
  49. package/package.json +10 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 - 2026-08-15
4
+
5
+ - Authority: weak lexical matches demote from `use` to `verify` (relevance
6
+ 0.12–0.5, non-capsule). The gate no longer confidently injects a memory
7
+ that only weakly overlaps the query — it labels it as an unverified hint.
8
+ - Fix capsule composition: a trusted global preference/constraint that also
9
+ matches lexically now keeps its capsule identity (unconditional use) instead
10
+ of being downgraded to a weak trigger match.
11
+ - Backtest: the three partial-overlap hard cases now resolve as `verify`
12
+ (weak matches); one synonym-coverage gap (改表 vs 修改 schema) is recorded
13
+ for follow-up. 30/30 clear scenarios pass, F1 = 1.000.
14
+
15
+ ## 0.4.0 - 2026-08-15
16
+
17
+ - Ship the evaluation data foundation (repo + npm tarball):
18
+ - Decision-layer backtest: 30 synthetic scenarios (all five claim kinds,
19
+ verify branches, quarantine, pollution, cross-scope, paraphrases) with a
20
+ four-leg comparison (gate / top-3 / random / shadow) and a clear-vs-hard
21
+ split; a hard case records the known partial-overlap over-trigger.
22
+ - Result-layer measurement: adoption / token-cost / effect scoring.
23
+ - Trajectory observer: parse DSH session logs (jsonl/zstd) and measure
24
+ per-turn injection adoption and effect.
25
+ - Release gate: `prepublishOnly` runs `check + test + backtest` before any
26
+ publish and aborts on clear-case regressions.
27
+ - No runtime behavior change to the plugin itself.
28
+
3
29
  ## 0.3.2 - 2026-08-14
4
30
 
5
31
  - Docs-only release: reposition from storage ("SQLite + FTS5 memory") to
package/README.md CHANGED
@@ -141,10 +141,17 @@ dsh plugin --profile web remove dsh-memory-gate
141
141
 
142
142
  ```bash
143
143
  npm install
144
- npm run check
144
+ npm run check # 类型检查 + 构建
145
+ npm test # 21 个单元/集成测试
146
+ npm run backtest # 决策层回测:30 场景四腿对照(gate/top-3/random/shadow)
147
+ npm run result # 结果层:注入采纳度 / 成本 / 效果分
148
+ npm run observe <log> # 轨迹观测:对真实 session 日志量采纳度
145
149
  npm pack --dry-run
146
150
  ```
147
151
 
152
+ 发布门:`prepublishOnly` 会在任何 `npm publish` 前自动跑 `check + test +
153
+ backtest`,回测清晰场景不过关即中止发布。
154
+
148
155
  发布前的三轮基准中位数(Node.js 22.22.1,1001 条合成记忆,每轮 300
149
156
  次查询):WSL 磁盘上的触发检索 p95 `5.343ms`,包含 CBDC 决策和 SQLite
150
157
  审计的完整召回 p95 `11.151ms`,三轮最大观测 p95 `11.663ms`。基准不会
@@ -0,0 +1,62 @@
1
+ /**
2
+ * 真实决策轨迹捕获 —— 把审计库(cbdc.sqlite)里已发生的「召回→决策→注入」
3
+ * 导出成样本(schema v0),供结果层回测(P1)与人工抽查使用。
4
+ *
5
+ * 注意:出于隐私设计,retrieval_runs 只存 query_hash 与词项,不存查询原文,
6
+ * 因此捕获样本的 query 是词项列表而非原文。
7
+ *
8
+ * 用法:node backtest/capture.mjs [path-to.sqlite] [outfile]
9
+ */
10
+ import { readFileSync } from 'node:fs'
11
+ import { mkdirSync, writeFileSync } from 'node:fs'
12
+ import { dirname, join, resolve } from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+ import { DatabaseSync } from 'node:sqlite'
15
+
16
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
17
+ const dbPath = process.argv[2] ?? '/home/ubuntu/.dsh/memory/cbdc.sqlite'
18
+ const outFile = process.argv[3] ?? join(ROOT, 'fixtures', 'captured', 'captured-latest.json')
19
+
20
+ const db = new DatabaseSync(dbPath, { readOnly: true })
21
+ try {
22
+ const runs = db
23
+ .prepare('SELECT id, query_terms_json, created_at FROM retrieval_runs ORDER BY created_at DESC LIMIT 50')
24
+ .all()
25
+
26
+ const samples = runs.map((run) => {
27
+ const decisions = db
28
+ .prepare(
29
+ `SELECT d.claim_id, d.action, d.belief_score, d.relevance_score, d.freshness_score, d.risk_score, d.reason_codes_json
30
+ FROM authority_decisions d WHERE d.run_id = ?`,
31
+ )
32
+ .all(run.id)
33
+ const injection = db
34
+ .prepare('SELECT claim_ids_json, mode, created_at FROM injections WHERE run_id = ? LIMIT 1')
35
+ .get(run.id)
36
+ const json = (s) => JSON.parse(s)
37
+ return {
38
+ schema: '1.0',
39
+ source: 'captured',
40
+ scenario_id: String(run.id),
41
+ created_at: String(run.created_at),
42
+ query_terms: json(run.query_terms_json),
43
+ decisions: decisions.map((d) => ({
44
+ claim_id: String(d.claim_id),
45
+ action: String(d.action),
46
+ belief: Number(d.belief_score),
47
+ relevance: Number(d.relevance_score),
48
+ freshness: Number(d.freshness_score),
49
+ risk: Number(d.risk_score),
50
+ reasons: json(d.reason_codes_json),
51
+ })),
52
+ injection: injection ? { claim_ids: json(injection.claim_ids_json), mode: String(injection.mode) } : null,
53
+ }
54
+ })
55
+
56
+ mkdirSync(dirname(outFile), { recursive: true })
57
+ writeFileSync(outFile, JSON.stringify(samples, null, 1) + '\n')
58
+ console.log(`captured ${samples.length} runs → ${outFile}`)
59
+ if (samples.length > 0) console.log(`sample keys: ${Object.keys(samples[0]).join(', ')}`)
60
+ } finally {
61
+ db.close()
62
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * 结果层测量(纯函数)—— 从「注入的 memory」与「assistant 的最终回答」算采纳度 / 成本 / 效果分。
3
+ *
4
+ * 这是 P1 轨迹观测仪的核心度量,先做成可单测的纯函数,离线、零模型调用。
5
+ * 局限(诚实的):采纳度基于词项重叠,对 fact/procedure(回答会复述内容)有效;
6
+ * 对 preference/constraint(模型"照做"但不会复述"简洁"这个词)是弱信号,留待后续。
7
+ */
8
+ import { extractTerms } from '../lib/index.js'
9
+
10
+ /** 粗略双语 token 估算:CJK ~1 token/字,其余 ~1 token/4 字符。 */
11
+ export function estimateTokens(text) {
12
+ const cjk = (text.match(/[\p{Script=Han}]/gu) ?? []).length
13
+ const other = text.replace(/[\p{Script=Han}\s]/gu, '')
14
+ return cjk + Math.ceil(other.length / 4)
15
+ }
16
+
17
+ /** 注入 claim 的「特有词项」:claim 词项中不在 query 词项里的那部分。 */
18
+ export function distinctiveTerms(claimTerms, queryTerms) {
19
+ const query = new Set(queryTerms)
20
+ return claimTerms.filter((term) => !query.has(term))
21
+ }
22
+
23
+ /** 采纳度:特有词项出现在 assistant 最终回答里的比例(0~1)。 */
24
+ export function adoptionScore(distinctive, assistantText) {
25
+ if (!distinctive.length) return 0
26
+ const tokens = new Set(extractTerms(assistantText, 500))
27
+ const hits = distinctive.filter((term) => tokens.has(term)).length
28
+ return hits / distinctive.length
29
+ }
30
+
31
+ /** 效果分:采纳 + 结果质量 − 成本惩罚,输出 [-1, +1]。 */
32
+ export function effectScore({ adoption, quality, tokenCost, costWeight = 0.002, adoptionWeight = 0.6, qualityWeight = 0.4 }) {
33
+ const costPenalty = Math.min(1, tokenCost * costWeight)
34
+ const score = adoptionWeight * adoption + qualityWeight * quality - costPenalty
35
+ return Math.max(-1, Math.min(1, score))
36
+ }
37
+
38
+ /** 质量信号 0~1:有正反馈为 1、无信号 0.5、有负反馈 0(先验,待真实数据校准)。 */
39
+ export function qualitySignal(feedback) {
40
+ if (feedback === 'helped') return 1
41
+ if (feedback === 'harmful' || feedback === 'stale' || feedback === 'conflict') return 0
42
+ return 0.5
43
+ }
@@ -0,0 +1,43 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+
4
+ import { adoptionScore, distinctiveTerms, effectScore, estimateTokens, qualitySignal } from '../backtest/measure.mjs'
5
+
6
+ test('estimateTokens: CJK ~1/字,latin ~1/4 字符', () => {
7
+ assert.equal(estimateTokens('中文'), 2)
8
+ assert.ok(estimateTokens('abcd') >= 1)
9
+ assert.ok(estimateTokens('中文 abcd') >= 3)
10
+ })
11
+
12
+ test('distinctiveTerms 排除 query 词项', () => {
13
+ const distinctive = distinctiveTerms(['简洁', '中文', '目录'], ['简洁'])
14
+ assert.deepEqual(distinctive, ['中文', '目录'])
15
+ })
16
+
17
+ test('adoptionScore 度量特有词项命中比例', () => {
18
+ const distinctive = ['目录']
19
+ assert.equal(adoptionScore(distinctive, '项目代码在 /home/ubuntu/dsh 目录'), 1)
20
+ assert.equal(adoptionScore(distinctive, '这个我不清楚'), 0)
21
+ // 同义折叠在「别名令牌」层生效:claim 词项含 recall_alias_deploy,
22
+ // 回答里说「上线」也会折叠到同一令牌,从而命中。
23
+ assert.equal(adoptionScore(['recall_alias_deploy'], '上线流程是这样的'), 1)
24
+ })
25
+
26
+ test('adoptionScore 空特有词返回 0(无信号不算采纳)', () => {
27
+ assert.equal(adoptionScore([], '随便'), 0)
28
+ })
29
+
30
+ test('effectScore 正采纳加分、成本减分、有界', () => {
31
+ const good = effectScore({ adoption: 1, quality: 1, tokenCost: 0 })
32
+ assert.ok(good > 0.9)
33
+ const costly = effectScore({ adoption: 1, quality: 1, tokenCost: 2000, costWeight: 0.002 })
34
+ assert.ok(costly < good, '成本应拉低效果分')
35
+ assert.equal(effectScore({ adoption: 0, quality: 0, tokenCost: 1e9 }), -1, '有下界 -1')
36
+ assert.equal(effectScore({ adoption: 1, quality: 1, tokenCost: 1e9 }), 0, '满分减满成本惩罚为 0')
37
+ })
38
+
39
+ test('qualitySignal 映射反馈到质量', () => {
40
+ assert.equal(qualitySignal('helped'), 1)
41
+ assert.equal(qualitySignal('harmful'), 0)
42
+ assert.equal(qualitySignal(undefined), 0.5)
43
+ })
@@ -0,0 +1,56 @@
1
+ /**
2
+ * 轨迹观测 —— 把结果层度量接到真实/合成 session 轨迹上。
3
+ *
4
+ * 对每个含注入的回合:query 词项 → 注入块特有词项 → 在 assistant 最终回答里量采纳度
5
+ * → 合成效果分。支持 .jsonl 与 .jsonl.zstd(经 zstd CLI 解压)。
6
+ *
7
+ * 用法:node backtest/observe.mjs <session.jsonl|session.jsonl.zstd>
8
+ */
9
+ import { execFileSync } from 'node:child_process'
10
+ import { readFileSync } from 'node:fs'
11
+ import { resolve } from 'node:path'
12
+
13
+ import { extractTerms } from '../lib/index.js'
14
+ import { adoptionScore, distinctiveTerms, effectScore, estimateTokens, qualitySignal } from './measure.mjs'
15
+ import { observeSession, parseEvents } from './trajectory.mjs'
16
+
17
+ const file = process.argv[2]
18
+ if (!file) {
19
+ console.error('用法: node backtest/observe.mjs <session.jsonl|session.jsonl.zstd>')
20
+ process.exit(2)
21
+ }
22
+
23
+ const path = resolve(file)
24
+ let text
25
+ if (path.endsWith('.zstd')) {
26
+ text = execFileSync('zstd', ['-dc', path], { maxBuffer: 256 * 1024 * 1024 }).toString('utf8')
27
+ } else {
28
+ text = readFileSync(path, 'utf8')
29
+ }
30
+
31
+ const turns = observeSession(parseEvents(text))
32
+ const rows = []
33
+ for (const turn of turns) {
34
+ if (!turn.injections.length) continue
35
+ const queryTerms = extractTerms(turn.query)
36
+ const memoryText = turn.injections.flatMap((i) => i.contents ?? [i.text]).join('\n')
37
+ const memoryTerms = extractTerms(memoryText, 500)
38
+ const distinctive = distinctiveTerms(memoryTerms, queryTerms)
39
+ const adoption = adoptionScore(distinctive, turn.assistantText)
40
+ const tokenCost = estimateTokens(memoryText)
41
+ const effect = effectScore({ adoption, quality: qualitySignal(undefined), tokenCost })
42
+ rows.push({ turn: turn.turn, claims: turn.injections.flatMap((i) => i.claimIds).length, adoption, tokenCost, effect })
43
+ }
44
+
45
+ if (!rows.length) {
46
+ console.log('(该轨迹中未检测到记忆注入——等 dogfood 存了记忆、有了真实注入后这里才有数据)')
47
+ } else {
48
+ console.log('| 回合 | 注入条数 | 采纳度 | token 成本 | 效果分 |')
49
+ console.log('|---|---|---|---|---|')
50
+ for (const r of rows) {
51
+ console.log(`| #${r.turn} | ${r.claims} | ${r.adoption.toFixed(2)} | ${r.tokenCost} | ${r.effect.toFixed(2)} |`)
52
+ }
53
+ const avgAdoption = rows.reduce((s, r) => s + r.adoption, 0) / rows.length
54
+ const avgEffect = rows.reduce((s, r) => s + r.effect, 0) / rows.length
55
+ console.log(`\n注入 ${rows.length} 次;平均采纳度 ${avgAdoption.toFixed(2)};平均效果分 ${avgEffect.toFixed(2)}`)
56
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * 结果层回测演示 —— 决策层只回答"该不该注入";这里回答"注入后被用上了吗、值不值"。
3
+ *
4
+ * 对每个带 golden.assistant_answer 的场景:跑 gate → 对注入的 claim 算
5
+ * 特有词项 → 在 assistant 最终回答里量采纳度 → 合成为效果分。
6
+ * 纯离线、零模型调用(assistant_answer 是场景里写好的金标回答)。
7
+ *
8
+ * 用法:npm run result
9
+ */
10
+ import { readFileSync, readdirSync } from 'node:fs'
11
+ import { dirname, join, resolve } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ import { MemoryRepository, MemoryService, extractTerms } from '../lib/index.js'
15
+ import { adoptionScore, distinctiveTerms, effectScore, estimateTokens, qualitySignal } from './measure.mjs'
16
+
17
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
18
+ const FIXTURES = join(ROOT, 'fixtures', 'synthetic')
19
+
20
+ const CONFIG = {
21
+ mode: 'assist',
22
+ automaticExtraction: false,
23
+ candidateLimit: 16,
24
+ capsuleLimit: 2,
25
+ injectionLimit: 3,
26
+ maxInjectionChars: 1200,
27
+ auditRetentionRuns: 5000,
28
+ minUseBelief: 0.7,
29
+ maxUseRisk: 0.45,
30
+ harmfulQuarantineThreshold: 2,
31
+ freshnessHalfLifeDays: 180,
32
+ }
33
+
34
+ function buildStore(scenario) {
35
+ const repo = new MemoryRepository(':memory:')
36
+ const svc = new MemoryService(repo, CONFIG)
37
+ const idByIndex = new Map()
38
+ scenario.claim_store.forEach((claim, index) => {
39
+ const scope = claim.scope ?? 'global'
40
+ const scopeKey = claim.scope_key ?? scope
41
+ const { claim: created } = repo.remember({
42
+ scope,
43
+ scopeKey,
44
+ kind: claim.kind,
45
+ content: claim.content,
46
+ origin: claim.origin ?? 'explicit',
47
+ ...(claim.valid_until === undefined ? {} : { validUntil: claim.valid_until }),
48
+ })
49
+ idByIndex.set(index, created.id)
50
+ })
51
+ return { repo, svc, idByIndex }
52
+ }
53
+
54
+ const scenarios = readdirSync(FIXTURES)
55
+ .filter((name) => name.endsWith('.json'))
56
+ .sort()
57
+ .map((name) => JSON.parse(readFileSync(join(FIXTURES, name), 'utf8')))
58
+ .filter((s) => s.golden?.assistant_answer && s.claim_store.length > 0)
59
+
60
+ const rows = []
61
+ for (const scenario of scenarios) {
62
+ const { repo, svc, idByIndex } = buildStore(scenario)
63
+ const retrieval = svc.retrieve({ query: scenario.query, sessionId: 'backtest', sessionScopeKey: 'backtest' })
64
+ const injectedIndices = retrieval.decisions.filter((d) => d.action !== 'ignore').map((d) => {
65
+ for (const [index, id] of idByIndex) if (id === d.claimId) return index
66
+ return -1
67
+ }).filter((i) => i >= 0)
68
+
69
+ const queryTerms = extractTerms(scenario.query)
70
+ const distinctive = new Set()
71
+ let tokenCost = 0
72
+ for (const index of injectedIndices) {
73
+ const claim = repo.getClaim(idByIndex.get(index))
74
+ if (!claim) continue
75
+ for (const term of distinctiveTerms(claim.terms, queryTerms)) distinctive.add(term)
76
+ tokenCost += estimateTokens(claim.content)
77
+ }
78
+
79
+ const adoption = adoptionScore([...distinctive], scenario.golden.assistant_answer)
80
+ const quality = qualitySignal(scenario.golden.feedback)
81
+ const effect = effectScore({ adoption, quality, tokenCost })
82
+ rows.push({ scenario: scenario.scenario_id, injected: injectedIndices.length, adoption, quality, tokenCost, effect })
83
+ repo.close()
84
+ }
85
+
86
+ console.log('| 场景 | 注入条数 | 采纳度 | 质量 | token 成本 | 效果分 |')
87
+ console.log('|---|---|---|---|---|---|')
88
+ for (const r of rows) {
89
+ console.log(`| ${r.scenario} | ${r.injected} | ${r.adoption.toFixed(2)} | ${r.quality.toFixed(1)} | ${r.tokenCost} | ${r.effect.toFixed(2)} |`)
90
+ }
91
+ if (rows.length) {
92
+ const avgAdoption = rows.reduce((s, r) => s + r.adoption, 0) / rows.length
93
+ const avgEffect = rows.reduce((s, r) => s + r.effect, 0) / rows.length
94
+ console.log(`\n平均采纳度 ${avgAdoption.toFixed(2)},平均效果分 ${avgEffect.toFixed(2)}`)
95
+ } else {
96
+ console.log('\n(没有带 assistant_answer 的场景,结果层为空)')
97
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * 决策层回测引擎 —— gate 的「因子回测」。
3
+ *
4
+ * 四条腿对照同一批合成场景:
5
+ * gate 真实 retrieve → CBDC 决策(use|verify 记为注入,ignore 记为不注入)
6
+ * top-3 无门控基线:词法检索 top-3 全注入(capsule 不参与)
7
+ * random 随机注入(固定种子,可复现)
8
+ * shadow 零注入基线
9
+ *
10
+ * 两层评分:
11
+ * - 注入集合 vs 金标(claim 级精确率/召回率/F1,四腿对照)
12
+ * - gate 的动作(use/verify/ignore)vs 金标 expected_action(仅 gate 腿)
13
+ *
14
+ * 硬案例(hard=true)与清晰案例分开计分:硬案例记录已知短板、不阻塞发版;
15
+ * CI 门只要求清晰场景全对(clear F1 == 1.0)。
16
+ *
17
+ * 用法:npm run backtest
18
+ */
19
+ import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
20
+ import { dirname, join, resolve } from 'node:path'
21
+ import { fileURLToPath } from 'node:url'
22
+
23
+ import { MemoryRepository, MemoryService } from '../lib/index.js'
24
+
25
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
26
+ const FIXTURES = join(ROOT, 'fixtures', 'synthetic')
27
+ const REPORTS = join(ROOT, 'reports')
28
+ const INJECTION_LIMIT = 3
29
+
30
+ const CONFIG = {
31
+ mode: 'assist',
32
+ automaticExtraction: false,
33
+ candidateLimit: 16,
34
+ capsuleLimit: 2,
35
+ injectionLimit: INJECTION_LIMIT,
36
+ maxInjectionChars: 1200,
37
+ auditRetentionRuns: 5000,
38
+ minUseBelief: 0.7,
39
+ maxUseRisk: 0.45,
40
+ harmfulQuarantineThreshold: 2,
41
+ freshnessHalfLifeDays: 180,
42
+ }
43
+
44
+ const loadScenarios = () =>
45
+ readdirSync(FIXTURES)
46
+ .filter((name) => name.endsWith('.json'))
47
+ .sort()
48
+ .map((name) => JSON.parse(readFileSync(join(FIXTURES, name), 'utf8')))
49
+
50
+ const seededRandom = (seed) => {
51
+ let state = seed >>> 0
52
+ return () => {
53
+ state = (state * 1664525 + 1013904223) >>> 0
54
+ return state / 0x100000000
55
+ }
56
+ }
57
+
58
+ function buildStore(scenario) {
59
+ const repo = new MemoryRepository(':memory:')
60
+ const svc = new MemoryService(repo, CONFIG)
61
+ const idToIndex = new Map()
62
+ scenario.claim_store.forEach((claim, index) => {
63
+ const scope = claim.scope ?? 'global'
64
+ const scopeKey = claim.scope_key ?? scope
65
+ const { claim: created } = repo.remember({
66
+ scope,
67
+ scopeKey,
68
+ kind: claim.kind,
69
+ content: claim.content,
70
+ origin: claim.origin ?? 'explicit',
71
+ ...(claim.valid_until === undefined ? {} : { validUntil: claim.valid_until }),
72
+ })
73
+ idToIndex.set(created.id, index)
74
+ for (let i = 0; i < (claim.harmful_bootstrap ?? 0); i += 1) {
75
+ repo.recordConsumption(created.id, 'harmful', 'backtest-bootstrap')
76
+ }
77
+ })
78
+ return { repo, svc, idToIndex }
79
+ }
80
+
81
+ function runScenario(scenario) {
82
+ const { repo, svc, idToIndex } = buildStore(scenario)
83
+ const goldenSet = new Set(scenario.golden.inject ?? [])
84
+ const expectedAction = (index) => (goldenSet.has(index) ? scenario.golden.expected_action : 'ignore')
85
+
86
+ const retrieval = svc.retrieve({ query: scenario.query, sessionId: 'backtest', sessionScopeKey: 'backtest' })
87
+ const gateDecisions = retrieval.decisions.map((d) => ({ index: idToIndex.get(d.claimId), action: d.action }))
88
+ const gateInject = new Set(gateDecisions.filter((d) => d.action !== 'ignore').map((d) => d.index))
89
+
90
+ const topCandidates = repo.search(scenario.query, ['global'], INJECTION_LIMIT)
91
+ const topInject = new Set(topCandidates.map((c) => idToIndex.get(c.claim.id)).filter((i) => i !== undefined))
92
+
93
+ const rand = seededRandom(0x5eed + scenario.claim_store.length)
94
+ const pool = scenario.claim_store.map((_, i) => i)
95
+ const randomInject = new Set()
96
+ for (let i = 0; i < Math.min(INJECTION_LIMIT, pool.length); i += 1) {
97
+ const pick = Math.floor(rand() * pool.length)
98
+ randomInject.add(pool[pick])
99
+ pool.splice(pick, 1)
100
+ }
101
+
102
+ const shadowInject = new Set()
103
+
104
+ const actionCorrect = scenario.claim_store.every((_, index) => {
105
+ const actual = gateDecisions.find((d) => d.index === index)?.action ?? 'ignore'
106
+ return actual === expectedAction(index)
107
+ })
108
+
109
+ repo.close()
110
+ return { scenario, goldenSet, gateInject, topInject, randomInject, shadowInject, actionCorrect }
111
+ }
112
+
113
+ const setsEqual = (a, b) => {
114
+ if (a.size !== b.size) return false
115
+ for (const item of a) if (!b.has(item)) return false
116
+ return true
117
+ }
118
+ const fmt = (set) => (set.size === 0 ? '∅' : [...set].sort((a, b) => a - b).join(','))
119
+
120
+ function aggregate(results) {
121
+ const acc = { gate: { tp: 0, fp: 0, fn: 0 }, 'top-3': { tp: 0, fp: 0, fn: 0 }, random: { tp: 0, fp: 0, fn: 0 }, shadow: { tp: 0, fp: 0, fn: 0 } }
122
+ const injectOf = (r, name) => (name === 'gate' ? r.gateInject : name === 'top-3' ? r.topInject : name === 'random' ? r.randomInject : r.shadowInject)
123
+ for (const r of results) {
124
+ for (const name of Object.keys(acc)) {
125
+ const inject = injectOf(r, name)
126
+ r.scenario.claim_store.forEach((_, index) => {
127
+ const label = r.goldenSet.has(index)
128
+ const predict = inject.has(index)
129
+ if (predict && label) acc[name].tp += 1
130
+ else if (predict && !label) acc[name].fp += 1
131
+ else if (!predict && label) acc[name].fn += 1
132
+ })
133
+ }
134
+ }
135
+ return acc
136
+ }
137
+
138
+ function prf(acc, name) {
139
+ const { tp, fp, fn } = acc[name]
140
+ const precision = tp + fp === 0 ? 0 : tp / (tp + fp)
141
+ const recall = tp + fn === 0 ? 0 : tp / (tp + fn)
142
+ const f1 = precision + recall === 0 ? 0 : (2 * precision * recall) / (precision + recall)
143
+ return { precision, recall, f1, tp, fp, fn }
144
+ }
145
+
146
+ const scenarios = loadScenarios()
147
+ const results = scenarios.map(runScenario)
148
+ const clear = results.filter((r) => r.scenario.golden.hard !== true)
149
+ const hard = results.filter((r) => r.scenario.golden.hard === true)
150
+
151
+ const clearAgg = aggregate(clear)
152
+ const hardAgg = aggregate(hard)
153
+
154
+ const lines = []
155
+ lines.push('# gate 决策层回测报告(合成场景)')
156
+ lines.push('')
157
+ lines.push(`场景总数 ${scenarios.length}(清晰 ${clear.length} / 硬案例 ${hard.length})`)
158
+ lines.push('')
159
+ lines.push('| 场景 | 金标 | gate | top-3 | random | shadow | 结果 |')
160
+ lines.push('|---|---|---|---|---|---|---|')
161
+
162
+ for (const r of results) {
163
+ const isHard = r.scenario.golden.hard === true
164
+ const ok = setsEqual(r.gateInject, r.goldenSet) && r.actionCorrect
165
+ const mark = isHard ? '⚠️' : ok ? '✓' : '✗'
166
+ lines.push(
167
+ `| ${r.scenario.scenario_id}${isHard ? ' (hard)' : ''} | ${fmt(r.goldenSet)} | ${fmt(r.gateInject)} | ${fmt(r.topInject)} | ${fmt(r.randomInject)} | ${fmt(r.shadowInject)} | ${mark} |`,
168
+ )
169
+ }
170
+
171
+ const metricTable = (title, agg, results) => {
172
+ lines.push('')
173
+ lines.push(`## ${title}`)
174
+ lines.push('')
175
+ lines.push('| 腿 | 精确率 | 召回率 | F1 | TP/FP/FN |')
176
+ lines.push('|---|---|---|---|---|')
177
+ for (const name of ['gate', 'top-3', 'random', 'shadow']) {
178
+ const m = prf(agg, name)
179
+ lines.push(`| ${name} | ${m.precision.toFixed(2)} | ${m.recall.toFixed(2)} | ${m.f1.toFixed(2)} | ${m.tp}/${m.fp}/${m.fn} |`)
180
+ }
181
+ const actionOk = results.filter((r) => r.actionCorrect).length
182
+ lines.push('')
183
+ lines.push(`gate 动作全对:${actionOk}/${results.length}`)
184
+ }
185
+
186
+ metricTable('清晰场景(CI 门依据)', clearAgg, clear)
187
+ metricTable('硬案例(已知短板,不阻塞)', hardAgg, hard)
188
+
189
+ const clearPass = clear.filter((r) => setsEqual(r.gateInject, r.goldenSet) && r.actionCorrect).length
190
+ const clearF1 = prf(clearAgg, 'gate').f1
191
+ const hardFails = hard.filter((r) => !(setsEqual(r.gateInject, r.goldenSet) && r.actionCorrect)).map((r) => r.scenario.scenario_id)
192
+
193
+ lines.push('')
194
+ lines.push('## 结论')
195
+ lines.push(`- 清晰场景通过:${clearPass}/${clear.length}`)
196
+ if (hardFails.length) lines.push(`- 已知硬案例(待攻):${hardFails.join(', ')}`)
197
+ else lines.push('- 硬案例全部通过(无已知短板)')
198
+
199
+ const report = lines.join('\n')
200
+ console.log(report)
201
+
202
+ mkdirSync(REPORTS, { recursive: true })
203
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
204
+ writeFileSync(join(REPORTS, `backtest-${ts}.md`), report + '\n')
205
+
206
+ // CI 门:清晰场景 gate 必须全对(即 clear F1 == 1.0,动作全对)。
207
+ const pass = clearPass === clear.length && clearF1 >= 0.999
208
+ if (!pass) {
209
+ console.error(`\n[CI gate] FAILED: clear ${clearPass}/${clear.length}, clear F1=${clearF1.toFixed(3)}`)
210
+ process.exit(1)
211
+ }
212
+ console.log(`\n[CI gate] PASSED: clear ${clearPass}/${clear.length}, clear F1=${clearF1.toFixed(3)}`)
@@ -0,0 +1,34 @@
1
+ {
2
+ "$comment": "dsh-memory-gate 回测样本 schema v0 —— 一条样本 = 一次「召回→决策→注入→结果」的闭环。决策层回测只填到 injection;outcome 由结果层回测(P1)再填。",
3
+ "schema": "1.0",
4
+ "source": "synthetic | captured",
5
+ "scenario_id": "pref-zh-concise",
6
+ "query": "请用简短的中文答复我",
7
+ "scopes": ["global"],
8
+ "claim_store": [
9
+ { "kind": "preference", "scope": "global", "content": "用户偏好简洁中文回答", "harmful_bootstrap": 0, "valid_until": null }
10
+ ],
11
+ "recall": {
12
+ "candidate_indices": [0],
13
+ "lexical_scores": [0.82]
14
+ },
15
+ "decisions": [
16
+ { "claim_index": 0, "action": "use", "belief": 0.86, "relevance": 0.82, "risk": 0.16, "reasons": ["authority_thresholds_passed"] }
17
+ ],
18
+ "injection": {
19
+ "claim_indices": [0],
20
+ "token_cost": 420,
21
+ "char_budget": 300
22
+ },
23
+ "outcome": {
24
+ "adoption": null,
25
+ "quality": null,
26
+ "feedback": null,
27
+ "effect_score": null
28
+ },
29
+ "golden": {
30
+ "inject": [0],
31
+ "expected_action": "use",
32
+ "hard": false
33
+ }
34
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * 轨迹解析器 —— 把 DSH 的 session 事件流解析成「回合」视图。
3
+ *
4
+ * 每回合产出:{ query(人类提问文本), assistantText(模型最终回答文本), injections[] }
5
+ * 注入检测:消息文本含 <long_term_memory> 标记,且能从中抽出 claim id。
6
+ *
7
+ * 纯函数、可单测;zstd 解压由调用方(observe.mjs)负责。
8
+ */
9
+
10
+ export function parseEvents(text) {
11
+ const events = []
12
+ for (const line of text.split('\n')) {
13
+ if (!line.trim()) continue
14
+ try {
15
+ events.push(JSON.parse(line))
16
+ } catch {
17
+ // 忽略无法解析的行(损坏/半行)
18
+ }
19
+ }
20
+ return events
21
+ }
22
+
23
+ function messageText(data) {
24
+ return (data?.content ?? []).map((part) => part?.text ?? '').join('\n')
25
+ }
26
+
27
+ function claimIdsOf(memoryText) {
28
+ const ids = [...memoryText.matchAll(/mem_[0-9a-f-]+/g)].map((m) => m[0])
29
+ return [...new Set(ids)]
30
+ }
31
+
32
+ /** 从记忆块里抽出每条 claim 的正文(去掉 `- [USE #1 mem_x kind]` 前缀与 boilerplate)。 */
33
+ function claimContentsOf(memoryText) {
34
+ return memoryText
35
+ .split('\n')
36
+ .filter((line) => line.trimStart().startsWith('- ['))
37
+ .map((line) => line.replace(/^- \[[^\]]*\]\s*/, '').trim())
38
+ .filter(Boolean)
39
+ }
40
+
41
+ /**
42
+ * @param {Array} events 已解析的事件数组
43
+ * @returns {Array<{turn:number, query:string, assistantText:string, injections:Array<{claimIds:string[], text:string}>}>}
44
+ */
45
+ export function observeSession(events) {
46
+ let currentTurn = 0
47
+ const turns = new Map()
48
+ const ensure = (turn) => {
49
+ if (!turns.has(turn)) turns.set(turn, { turn, query: '', assistantText: '', injections: [] })
50
+ return turns.get(turn)
51
+ }
52
+
53
+ for (const event of events) {
54
+ const type = event?.type
55
+ const data = event?.data ?? {}
56
+ if (type === 'turn/start') {
57
+ currentTurn = data.turn ?? currentTurn
58
+ ensure(currentTurn)
59
+ } else if (type === 'user/message') {
60
+ const text = messageText(data)
61
+ if (text.includes('<long_term_memory>')) {
62
+ ensure(currentTurn).injections.push({ claimIds: claimIdsOf(text), text, contents: claimContentsOf(text) })
63
+ } else if (text.trim()) {
64
+ const turn = ensure(currentTurn)
65
+ turn.query = turn.query ? `${turn.query}\n${text}` : text
66
+ }
67
+ } else if (type === 'assistant/message') {
68
+ const turn = data.turn ?? currentTurn
69
+ const texts = (data.message?.content ?? []).filter((part) => part.type === 'text').map((part) => part.text)
70
+ ensure(turn).assistantText += texts.join('\n')
71
+ }
72
+ }
73
+ return [...turns.values()]
74
+ }