按 docs/feasibility.md §7.1 阶段 A 落地最小可验证切片,
刻意不引入 embedding、不碰 L1 淘汰。
落地内容
- lib/store.js BlockStore:node:sqlite 建 memory_blocks /
memory_block_edges / graph_centroid 三表 + FTS5 索引。
内容派生 id(blk_+sha256[:12],移植 distill.BlockID);
putBlock 走「空值不覆盖」,重复 commit 幂等。
- lib/recall.js FTS5 bm25 + 精确串布尔置顶(移植 SymbolScore/fusion.go:427)、
单字符不成符号(abstain.go:115)、前缀特异性 tie-break、
CJK 2..4 字滑窗兜底(移植 cjkWindows/fusion.go:240)。
- lib/index.js StableMem service + systemPrompt.context 常驻索引 +
agent/pre-step 按本轮输入召回注入 + stablemem_commit 工具。
- scripts/checkpoints.js V1–V5 + 两条变异,对真实 cordis 服务跑。
- test/ 26 个测试(store / recall / plugin wiring / real loader / isolate)。
- scripts/link-dsh.sh 把全局 dsh 包软链进 node_modules(插件本身零依赖)。
三个实测出来的 dsh 约束(都是先红了才发现的)
1. cordis service 不能用 JS #private 字段 —— ctx.<name> 是 proxy,
访问私有字段抛 "Cannot read private member"。状态必须放普通属性。
2. isolate() 不隔离 prompt context 名与工具名(只隔离 service 查找)——
所以工具名做成可配置,prompt context 名由 db 路径派生以保唯一。
3. systemPrompt.getContextOrder() 是分配器不是常量表,用命名槽位。
验证
- npm test → 26/26 通过
- npm run check → 8/8 checkpoint 通过
V1 sqlite/WAL/100 写回读;V2 能力分层(tier1 30/30、tier2 20/20,
tier3/4 作为已知缺口显式记录);V3 AssembleContext 仅 [agent];
V3b 注入落进批次且带 source.kind;V3b† 静默召回则判据红;
V4 工具 schema+render 端到端;V5 fiber dispose 释放句柄并可重开;
A5† 退化成包含匹配则区分度消失。
阶段 A exit criteria 全部满足(含 V3/V3b 分工结论写入 README)。
参考仓 /home/program/TrueAgent 未改动。
202 lines
7.7 KiB
JavaScript
202 lines
7.7 KiB
JavaScript
/**
|
||
* Unit tests for dsh-memory store + recall (phase A).
|
||
*
|
||
* Complements `scripts/checkpoints.js`: the checkpoints prove the harness wiring
|
||
* (real cordis, real SystemPrompt/ToolRuntime); these prove the algorithm and
|
||
* carry the mutation assertions that must be able to turn red.
|
||
*
|
||
* Run: node --test test/
|
||
*
|
||
* @module dsh-memory/test/store
|
||
*/
|
||
|
||
import assert from 'node:assert/strict'
|
||
import { mkdtempSync, rmSync } from 'node:fs'
|
||
import { tmpdir } from 'node:os'
|
||
import { join } from 'node:path'
|
||
import test from 'node:test'
|
||
import { BlockStore, blockId } from '../lib/store.js'
|
||
import {
|
||
buildMatchExpression,
|
||
cjkNeedles,
|
||
isNumericSymbol,
|
||
querySymbols,
|
||
recallBlocks,
|
||
renderRecall,
|
||
} from '../lib/recall.js'
|
||
|
||
/** @returns {{store: BlockStore, done: () => void}} */
|
||
function tempStore() {
|
||
const dir = mkdtempSync(join(tmpdir(), 'dsh-memory-test-'))
|
||
const store = new BlockStore({ path: join(dir, 'x.db') }).open()
|
||
return {
|
||
store,
|
||
done: () => {
|
||
store.close()
|
||
rmSync(dir, { recursive: true, force: true })
|
||
},
|
||
}
|
||
}
|
||
|
||
test('blockId is content-addressed and stable', () => {
|
||
assert.equal(blockId('值班室分机号 4324'), blockId('值班室分机号 4324'))
|
||
assert.notEqual(blockId('a'), blockId('b'))
|
||
assert.match(blockId('x'), /^blk_[0-9a-f]{12}$/)
|
||
})
|
||
|
||
test('putBlock is idempotent and keeps existing optional columns', () => {
|
||
const { store, done } = tempStore()
|
||
try {
|
||
const first = store.putBlock({ text: 'a note', source: 'qq', scene: 'chan:qq', semanticType: 'Fact' })
|
||
assert.equal(first.created, true)
|
||
// Re-commit with no optional fields: text updates, metadata must NOT be blanked.
|
||
const second = store.putBlock({ text: 'a note' })
|
||
assert.equal(second.created, false)
|
||
assert.equal(second.id, first.id)
|
||
const row = store.getBlock(first.id)
|
||
assert.equal(row.source, 'qq', 'source must survive a bare re-commit')
|
||
assert.equal(row.scene, 'chan:qq', 'scene must survive a bare re-commit')
|
||
assert.equal(row.semantic_type, 'Fact', 'semantic_type must survive a bare re-commit')
|
||
assert.equal(store.countBlocks(), 1)
|
||
} finally {
|
||
done()
|
||
}
|
||
})
|
||
|
||
test('edges are unique per (source,target,type)', () => {
|
||
const { store, done } = tempStore()
|
||
try {
|
||
const a = store.putBlock({ text: '值班室分机号 4324' }).id
|
||
const b = store.putBlock({ text: '4324 已停用' }).id
|
||
const edge = { sourceKind: 'block', sourceId: a, targetKind: 'block', targetId: b, type: 'superseded_by' }
|
||
assert.equal(store.addEdge(edge).created, true)
|
||
assert.equal(store.addEdge(edge).created, false, 'duplicate edge must not double')
|
||
assert.equal(store.countEdges(), 1)
|
||
} finally {
|
||
done()
|
||
}
|
||
})
|
||
|
||
test('querySymbols mirrors HologramAgent regexes and drops single chars', () => {
|
||
assert.deepEqual(querySymbols('本机 13010 端口'), ['13010'])
|
||
assert.deepEqual(querySymbols('版本 v2.31.5'), ['v2.31.5'])
|
||
assert.deepEqual(querySymbols('billing0 端口'), ['billing0'], 'no bare 0 from billing0')
|
||
// Single-character symbols are rejected (abstain.go:115 "单字不成符号").
|
||
assert.deepEqual(querySymbols('a 1 中'), [])
|
||
})
|
||
|
||
test('isNumericSymbol identifies digit-bearing symbols', () => {
|
||
assert.equal(isNumericSymbol('13010'), true)
|
||
assert.equal(isNumericSymbol('v2.31.5'), true)
|
||
assert.equal(isNumericSymbol('billing'), false)
|
||
})
|
||
|
||
test('buildMatchExpression quotes terms and refuses an empty query', () => {
|
||
assert.equal(buildMatchExpression(''), null)
|
||
assert.equal(buildMatchExpression(' '), null)
|
||
const expr = buildMatchExpression('billing 端口')
|
||
assert.ok(expr.includes('"billing"'), expr)
|
||
// Injected FTS5 operators must be neutralised, never passed through.
|
||
const hostile = buildMatchExpression('a OR b NEAR(c)')
|
||
assert.ok(!/\bNEAR\(/.test(hostile ?? ''), `FTS operators must be quoted, got ${hostile}`)
|
||
})
|
||
|
||
test('cjkNeedles yields 2..4-char windows plus the whole run', () => {
|
||
const needles = cjkNeedles('值班室分机号')
|
||
assert.ok(needles.includes('值班室分机号'), 'whole run present')
|
||
assert.ok(needles.includes('分机号'), '3-char window present')
|
||
assert.ok(needles.includes('分机'), '2-char window present')
|
||
assert.ok(!needles.includes('值'), 'single chars excluded')
|
||
})
|
||
|
||
test('exact numeric hit pins to the top above plain lexical matches', () => {
|
||
const { store, done } = tempStore()
|
||
try {
|
||
store.putBlock({ text: '值班室分机号 4379 已停用' })
|
||
store.putBlock({ text: '值班室分机号 4324,值班人 老周' })
|
||
const hits = recallBlocks(store, '4324 是多少', { topK: 5 })
|
||
assert.equal(hits[0].text.includes('4324'), true, `expected 4324 first, got ${hits[0].text}`)
|
||
assert.equal(hits[0].exact, true)
|
||
} finally {
|
||
done()
|
||
}
|
||
})
|
||
|
||
test('a matched word prefers the block where it is not a prefix of a longer word', () => {
|
||
const { store, done } = tempStore()
|
||
try {
|
||
store.putBlock({ text: 'billing2服务 端口=8501' })
|
||
store.putBlock({ text: 'billing服务 端口=8801' })
|
||
const hits = recallBlocks(store, 'billing 服务的端口是多少', { topK: 2 })
|
||
assert.equal(
|
||
hits[0].text.includes('8801'),
|
||
true,
|
||
`expected the exact "billing" note first, got ${hits[0].text}`,
|
||
)
|
||
} finally {
|
||
done()
|
||
}
|
||
})
|
||
|
||
test('unrelated query yields nothing (no silent everything-match)', () => {
|
||
const { store, done } = tempStore()
|
||
try {
|
||
store.putBlock({ text: '值班室分机号 4324' })
|
||
store.putBlock({ text: 'billing服务 端口=8499' })
|
||
assert.deepEqual(recallBlocks(store, 'zzz-不存在-qqq'), [])
|
||
} finally {
|
||
done()
|
||
}
|
||
})
|
||
|
||
test('renderRecall states the content is historical and yields "" for no hits', () => {
|
||
assert.equal(renderRecall([]), '')
|
||
const text = renderRecall([{ text: '值班室分机号 4324', exact: true, score: 1.01 }])
|
||
assert.ok(text.includes('PAST notes'), 'the model must be told these are historical')
|
||
assert.ok(text.includes('4324'))
|
||
})
|
||
|
||
// ── mutations: each must turn a specific assertion red ────────────────────
|
||
test('MUTATION: removing the numeric-gate makes a bare 0 pin noise', () => {
|
||
const { store, done } = tempStore()
|
||
try {
|
||
store.putBlock({ text: '发布批次 第8批|版本=v2.38.0' })
|
||
store.putBlock({ text: 'billing0服务 端口=8499' })
|
||
// Real behaviour: `billing0` is the symbol, so the billing note wins.
|
||
const real = recallBlocks(store, 'billing0 端口是多少', { topK: 1 })
|
||
assert.equal(real[0].text.includes('8499'), true, `expected billing note, got ${real[0].text}`)
|
||
// Mutation: pretend a bare `0` were a symbol — it appears in v2.38.0, so the
|
||
// unrelated note would pin to the top. Demonstrated directly:
|
||
const bare = querySymbols('billing0')
|
||
assert.ok(!bare.includes('0'), 'the single-char gate is what prevents this')
|
||
} finally {
|
||
done()
|
||
}
|
||
})
|
||
|
||
test('MUTATION: containment-only recall loses discrimination (the reason FTS is primary)', () => {
|
||
const { store, done } = tempStore()
|
||
try {
|
||
store.putBlock({ text: '值班室分机号 4324' })
|
||
store.putBlock({ text: '值班室分机号 4379 已停用' })
|
||
// Primary path: precise. One hit for an exact query.
|
||
assert.equal(recallBlocks(store, '4324', { topK: 5 }).length, 1)
|
||
// Mutated path: a shared CJK prefix matches both, so the answer becomes
|
||
// ambiguous. This is why FTS + symbol pin, not LIKE, is the primary path.
|
||
const contained = store.allBlocks(100).filter((row) => row.text_content.includes('值班室'))
|
||
assert.equal(contained.length, 2)
|
||
} finally {
|
||
done()
|
||
}
|
||
})
|
||
|
||
test('close is idempotent and a closed store refuses work', () => {
|
||
const { store, done } = tempStore()
|
||
store.putBlock({ text: 'x' })
|
||
store.close()
|
||
store.close() // must not throw
|
||
assert.equal(store.isOpen, false)
|
||
assert.throws(() => store.countBlocks(), /not open/)
|
||
done()
|
||
})
|