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.
- package/CHANGELOG.md +14 -0
- package/README.md +8 -1
- package/backtest/capture.mjs +62 -0
- package/backtest/measure.mjs +43 -0
- package/backtest/measure.test.mjs +43 -0
- package/backtest/observe.mjs +56 -0
- package/backtest/result.mjs +97 -0
- package/backtest/run.mjs +212 -0
- package/backtest/schema-v0.json +34 -0
- package/backtest/trajectory.mjs +74 -0
- package/backtest/trajectory.test.mjs +37 -0
- package/fixtures/synthetic/scenario-01-pref-zh-concise.json +11 -0
- package/fixtures/synthetic/scenario-02-constraint-no-creds.json +11 -0
- package/fixtures/synthetic/scenario-03-fact-project-path.json +11 -0
- package/fixtures/synthetic/scenario-04-quarantine-warning.json +11 -0
- package/fixtures/synthetic/scenario-05-pollution-irrelevant.json +11 -0
- package/fixtures/synthetic/scenario-06-procedure-deploy.json +11 -0
- package/fixtures/synthetic/scenario-07-heuristic-verify.json +11 -0
- package/fixtures/synthetic/scenario-08-warning-risk-verify.json +11 -0
- package/fixtures/synthetic/scenario-09-hard-partial-overlap.json +11 -0
- package/fixtures/synthetic/scenario-10-en-preference.json +11 -0
- package/fixtures/synthetic/scenario-11-traditional-simplified.json +11 -0
- package/fixtures/synthetic/scenario-12-warning-positive.json +11 -0
- package/fixtures/synthetic/scenario-13-expired-ignore.json +11 -0
- package/fixtures/synthetic/scenario-14-paraphrase-brief.json +11 -0
- package/fixtures/synthetic/scenario-15-paraphrase-refine.json +11 -0
- package/fixtures/synthetic/scenario-16-en-synonym.json +11 -0
- package/fixtures/synthetic/scenario-17-pollution-en-zh.json +11 -0
- package/fixtures/synthetic/scenario-18-scope-isolation.json +11 -0
- package/fixtures/synthetic/scenario-19-hard-overlap-deploy.json +11 -0
- package/fixtures/synthetic/scenario-20-hard-overlap-test.json +11 -0
- package/fixtures/synthetic/scenario-21-constraint-safety.json +11 -0
- package/fixtures/synthetic/scenario-22-fact-tool.json +11 -0
- package/fixtures/synthetic/scenario-23-procedure-multi-step.json +11 -0
- package/fixtures/synthetic/scenario-24-constraint-negative-zh.json +11 -0
- package/fixtures/synthetic/scenario-25-traditional-constraint.json +11 -0
- package/fixtures/synthetic/scenario-26-empty-store.json +9 -0
- package/fixtures/synthetic/scenario-27-multi-claim-select.json +12 -0
- package/fixtures/synthetic/scenario-28-warning-heuristic-verify.json +11 -0
- package/fixtures/synthetic/scenario-29-constraint-heuristic-verify.json +11 -0
- package/fixtures/synthetic/scenario-30-pollution-topic-mismatch.json +11 -0
- package/fixtures/synthetic/session-sample.jsonl +6 -0
- 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
|
+
}
|
package/backtest/run.mjs
ADDED
|
@@ -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,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
|
+
"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"
|