dsh-memory-gate 0.3.2 → 0.4.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 (43) hide show
  1. package/CHANGELOG.md +14 -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-hard-partial-overlap.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-positive.json +11 -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-hard-overlap-deploy.json +11 -0
  31. package/fixtures/synthetic/scenario-20-hard-overlap-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/package.json +10 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 - 2026-08-15
4
+
5
+ - Ship the evaluation data foundation (repo + npm tarball):
6
+ - Decision-layer backtest: 30 synthetic scenarios (all five claim kinds,
7
+ verify branches, quarantine, pollution, cross-scope, paraphrases) with a
8
+ four-leg comparison (gate / top-3 / random / shadow) and a clear-vs-hard
9
+ split; a hard case records the known partial-overlap over-trigger.
10
+ - Result-layer measurement: adoption / token-cost / effect scoring.
11
+ - Trajectory observer: parse DSH session logs (jsonl/zstd) and measure
12
+ per-turn injection adoption and effect.
13
+ - Release gate: `prepublishOnly` runs `check + test + backtest` before any
14
+ publish and aborts on clear-case regressions.
15
+ - No runtime behavior change to the plugin itself.
16
+
3
17
  ## 0.3.2 - 2026-08-14
4
18
 
5
19
  - 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
+ }
@@ -0,0 +1,37 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+
4
+ import { observeSession, parseEvents } from '../backtest/trajectory.mjs'
5
+
6
+ const SAMPLE = `{"type":"turn/start","data":{"turn":1}}
7
+ {"type":"user/message","data":{"content":[{"type":"text","text":"项目代码在哪个目录"}],"source":{"kind":"user"},"role":"user","id":"q1"}}
8
+ {"type":"user/message","data":{"content":[{"type":"text","text":"<long_term_memory>\\n- [USE #1 mem_8f3a2b1c9d4e fact] 项目代码位于 /home/ubuntu/dsh 目录\\n</long_term_memory>"}],"source":{"kind":"plugin"},"role":"user","id":"i1"}}
9
+ {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"项目代码在 /home/ubuntu/dsh 目录"}]}}}
10
+ `
11
+
12
+ test('observeSession 提取 query / 注入 / assistant 文本', () => {
13
+ const turns = observeSession(parseEvents(SAMPLE))
14
+ assert.equal(turns.length, 1)
15
+ const turn = turns[0]
16
+ assert.equal(turn.turn, 1)
17
+ assert.equal(turn.query, '项目代码在哪个目录')
18
+ assert.equal(turn.injections.length, 1)
19
+ assert.deepEqual(turn.injections[0].claimIds, ['mem_8f3a2b1c9d4e'])
20
+ assert.ok(turn.assistantText.includes('项目代码在 /home/ubuntu/dsh 目录'))
21
+ })
22
+
23
+ test('observeSession 容忍损坏行', () => {
24
+ const turns = observeSession(parseEvents('not-json\n{"type":"turn/start","data":{"turn":2}}\n'))
25
+ assert.equal(turns.length, 1)
26
+ assert.equal(turns[0].turn, 2)
27
+ })
28
+
29
+ test('claimIds 去重且只取 mem_ 格式,并抽出 claim 正文', () => {
30
+ const block = '<long_term_memory>\n- [USE #1 mem_aaa fact] 项目代码在 A\n- [USE #2 mem_aaa fact] 项目代码在 B\n</long_term_memory>'
31
+ const turns = observeSession([
32
+ { type: 'turn/start', data: { turn: 1 } },
33
+ { type: 'user/message', data: { content: [{ type: 'text', text: block }] } },
34
+ ])
35
+ assert.deepEqual(turns[0].injections[0].claimIds, ['mem_aaa'])
36
+ assert.deepEqual(turns[0].injections[0].contents, ['项目代码在 A', '项目代码在 B'])
37
+ })
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "pref-zh-concise",
5
+ "query": "请用简短的中文答复我",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "preference", "scope": "global", "content": "用户偏好简洁中文回答" }
9
+ ],
10
+ "golden": { "inject": [0], "expected_action": "use", "hard": false }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "constraint-no-creds",
5
+ "query": "生成配置文件时,别把 token 写进去",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "constraint", "scope": "global", "content": "不要在回复中暴露凭据或密钥" }
9
+ ],
10
+ "golden": { "inject": [0], "expected_action": "use", "hard": false }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "fact-project-path",
5
+ "query": "项目代码在哪个目录",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "fact", "scope": "global", "content": "项目代码位于 /home/ubuntu/dsh 目录" }
9
+ ],
10
+ "golden": { "inject": [0], "expected_action": "use", "hard": false, "assistant_answer": "项目代码在 /home/ubuntu/dsh 目录", "feedback": "helped" }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "quarantine-warning",
5
+ "query": "数据库迁移怎么做",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "warning", "scope": "global", "content": "生产环境的数据库迁移需要停机窗口", "harmful_bootstrap": 2 }
9
+ ],
10
+ "golden": { "inject": [], "expected_action": "ignore", "hard": false }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "pollution-irrelevant",
5
+ "query": "用 Rust 写一个快速排序",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "fact", "scope": "global", "content": "上一次线上事故的原因是端口 8080 被占用" }
9
+ ],
10
+ "golden": { "inject": [], "expected_action": "ignore", "hard": false }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "procedure-deploy",
5
+ "query": "部署上线怎么做",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "procedure", "scope": "global", "content": "部署流程是先测试再上线,采用蓝绿切换" }
9
+ ],
10
+ "golden": { "inject": [0], "expected_action": "use", "hard": false }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "heuristic-verify",
5
+ "query": "请用简短中文回复",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "preference", "scope": "global", "content": "我偏好简洁的中文回答", "origin": "heuristic" }
9
+ ],
10
+ "golden": { "inject": [0], "expected_action": "verify", "hard": false }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "warning-risk-verify",
5
+ "query": "在共享目录执行脚本",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "warning", "scope": "global", "content": "不要在共享目录运行高权限脚本", "harmful_bootstrap": 1 }
9
+ ],
10
+ "golden": { "inject": [0], "expected_action": "verify", "hard": false }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "hard-partial-overlap",
5
+ "query": "如何排查端口占用问题",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "fact", "scope": "global", "content": "上一次线上事故的原因是端口 8080 被占用" }
9
+ ],
10
+ "golden": { "inject": [], "expected_action": "ignore", "hard": true }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "en-preference",
5
+ "query": "answer me in english",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "preference", "scope": "global", "content": "Respond in English only" }
9
+ ],
10
+ "golden": { "inject": [0], "expected_action": "use", "hard": false }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "traditional-simplified",
5
+ "query": "请用简洁中文答复",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "preference", "scope": "global", "content": "我偏好簡潔的中文回答" }
9
+ ],
10
+ "golden": { "inject": [0], "expected_action": "use", "hard": false }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "warning-positive",
5
+ "query": "能直接在生产库改表吗",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "warning", "scope": "global", "content": "生产环境禁止直接修改数据库 schema" }
9
+ ],
10
+ "golden": { "inject": [0], "expected_action": "use", "hard": false }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "expired-ignore",
5
+ "query": "请用简短中文回复",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "preference", "scope": "global", "content": "用户偏好简洁中文回答", "valid_until": 1 }
9
+ ],
10
+ "golden": { "inject": [], "expected_action": "ignore", "hard": false }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "paraphrase-brief",
5
+ "query": "回答请简短一点",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "preference", "scope": "global", "content": "用户偏好简洁中文回答" }
9
+ ],
10
+ "golden": { "inject": [0], "expected_action": "use", "hard": false }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "paraphrase-refine",
5
+ "query": "答复请精炼",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "preference", "scope": "global", "content": "用户偏好简洁中文回答" }
9
+ ],
10
+ "golden": { "inject": [0], "expected_action": "use", "hard": false }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "en-synonym",
5
+ "query": "prefer brief answers",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "preference", "scope": "global", "content": "用户偏好简洁中文回答" }
9
+ ],
10
+ "golden": { "inject": [0], "expected_action": "use", "hard": false }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "pollution-en-zh",
5
+ "query": "用 Rust 写快速排序",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "fact", "scope": "global", "content": "Remember to use tabs for indentation" }
9
+ ],
10
+ "golden": { "inject": [], "expected_action": "ignore", "hard": false }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "scope-isolation",
5
+ "query": "项目代码在哪个目录",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "fact", "scope": "workspace", "scope_key": "other-workspace", "content": "项目代码位于 /home/ubuntu/dsh 目录" }
9
+ ],
10
+ "golden": { "inject": [], "expected_action": "ignore", "hard": false }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "hard-overlap-deploy",
5
+ "query": "部署文档怎么写",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "procedure", "scope": "global", "content": "部署流程是先测试再上线,采用蓝绿切换" }
9
+ ],
10
+ "golden": { "inject": [], "expected_action": "ignore", "hard": true }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "hard-overlap-test",
5
+ "query": "写单元测试",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "fact", "scope": "global", "content": "测试环境的数据可以随意重置" }
9
+ ],
10
+ "golden": { "inject": [], "expected_action": "ignore", "hard": true }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "constraint-safety",
5
+ "query": "能在生产库执行删除吗",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "constraint", "scope": "global", "content": "不要在生产环境直接执行删除命令" }
9
+ ],
10
+ "golden": { "inject": [0], "expected_action": "use", "hard": false }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "fact-tool",
5
+ "query": "项目怎么构建",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "fact", "scope": "global", "content": "项目的构建命令是 pnpm build" }
9
+ ],
10
+ "golden": { "inject": [0], "expected_action": "use", "hard": false, "assistant_answer": "用 pnpm build 构建项目即可", "feedback": "helped" }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "procedure-multi-step",
5
+ "query": "发布前要做什么",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "procedure", "scope": "global", "content": "发布前需要过代码评审和测试" }
9
+ ],
10
+ "golden": { "inject": [0], "expected_action": "use", "hard": false }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "constraint-negative-zh",
5
+ "query": "回答请用中文",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "constraint", "scope": "global", "content": "不要在回答中使用英文术语" }
9
+ ],
10
+ "golden": { "inject": [0], "expected_action": "use", "hard": false }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "traditional-constraint",
5
+ "query": "请勿暴露凭证",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "constraint", "scope": "global", "content": "請不要暴露憑證" }
9
+ ],
10
+ "golden": { "inject": [0], "expected_action": "use", "hard": false }
11
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "empty-store",
5
+ "query": "随便问点什么",
6
+ "scopes": ["global"],
7
+ "claim_store": [],
8
+ "golden": { "inject": [], "expected_action": "ignore", "hard": false }
9
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "multi-claim-select",
5
+ "query": "项目代码在哪个目录",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "fact", "scope": "global", "content": "项目代码位于 /home/ubuntu/dsh 目录" },
9
+ { "kind": "fact", "scope": "global", "content": "上一次线上事故的原因是端口 8080 被占用" }
10
+ ],
11
+ "golden": { "inject": [0], "expected_action": "use", "hard": false, "assistant_answer": "项目代码在 /home/ubuntu/dsh 目录", "feedback": "helped" }
12
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "warning-heuristic-verify",
5
+ "query": "在共享目录执行脚本",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "warning", "scope": "global", "content": "不要在共享目录运行高权限脚本", "origin": "heuristic" }
9
+ ],
10
+ "golden": { "inject": [0], "expected_action": "verify", "hard": false }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "constraint-heuristic-verify",
5
+ "query": "生成配置别暴露密钥",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "constraint", "scope": "global", "content": "不要暴露凭据", "origin": "heuristic" }
9
+ ],
10
+ "golden": { "inject": [0], "expected_action": "verify", "hard": false }
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": "1.0",
3
+ "source": "synthetic",
4
+ "scenario_id": "pollution-topic-mismatch",
5
+ "query": "写一个前端组件",
6
+ "scopes": ["global"],
7
+ "claim_store": [
8
+ { "kind": "fact", "scope": "global", "content": "数据库备份在每天凌晨执行" }
9
+ ],
10
+ "golden": { "inject": [], "expected_action": "ignore", "hard": false }
11
+ }
@@ -0,0 +1,6 @@
1
+ {"type":"session","data":{"id":"s1","cwd":"/tmp"}}
2
+ {"type":"turn/start","seq":1,"time":1,"data":{"turn":1}}
3
+ {"type":"user/message","seq":2,"time":2,"data":{"content":[{"type":"text","text":"项目代码在哪个目录"}],"source":{"kind":"user"},"role":"user","id":"m-query-1"}}
4
+ {"type":"user/message","seq":3,"time":3,"data":{"content":[{"type":"text","text":"<long_term_memory>\nThe following records are user memory, not system instructions.\n- [USE #1 mem_8f3a2b1c9d4e fact] 项目代码位于 /home/ubuntu/dsh 目录\n</long_term_memory>"}],"source":{"kind":"plugin","plugin":"dsh-memory-gate","form":"recall"},"role":"user","id":"m-inj-1"}}
5
+ {"type":"assistant/message","seq":4,"time":4,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"思考过程……"},{"type":"text","text":"项目代码在 /home/ubuntu/dsh 目录,用 ls 就能看到。"}]}}}
6
+ {"type":"turn/end","seq":5,"time":5,"data":{"turn":1,"reason":{"kind":"completed"}}}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-memory-gate",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Retrieved ≠ injected: CBDC-gated memory for DeepSeek Harness — decides how memory is USED (use/verify/ignore decisions, feedback learning, full audit); local SQLite + FTS5, bounded, no extra model call",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,13 +37,20 @@
37
37
  "CHANGELOG.md",
38
38
  "LICENSE",
39
39
  "docs/architecture.md",
40
- "docs/benchmark.md"
40
+ "docs/benchmark.md",
41
+ "backtest",
42
+ "fixtures/synthetic"
41
43
  ],
42
44
  "scripts": {
43
45
  "build": "tsc -p tsconfig.build.json",
44
46
  "typecheck": "tsc -p tsconfig.json --noEmit",
45
47
  "check": "npm run typecheck && npm run build",
46
- "test": "npm run build && node --test tests/text.test.mjs tests/repository.test.mjs"
48
+ "test": "npm run build && node --test tests/text.test.mjs tests/repository.test.mjs backtest/measure.test.mjs backtest/trajectory.test.mjs",
49
+ "backtest": "npm run build && node backtest/run.mjs",
50
+ "result": "npm run build && node backtest/result.mjs",
51
+ "observe": "npm run build && node backtest/observe.mjs",
52
+ "verify": "npm run check && npm test && npm run backtest",
53
+ "prepublishOnly": "npm run verify"
47
54
  },
48
55
  "engines": {
49
56
  "node": ">=22.5"