Compare commits
77 Commits
feat/feasi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f3ee2d1cb0 | |||
| 7652184832 | |||
| 59e6a71f28 | |||
| 73a0a6b8d0 | |||
| 5cfc982f39 | |||
| 7ad76167c5 | |||
| 5462934a84 | |||
| 16cfdb75d6 | |||
| 44f883745f | |||
| 0bc160b1bd | |||
| bdd91234a5 | |||
| 891582041d | |||
| 07f8438964 | |||
| 68775d327f | |||
| c97775bd18 | |||
| 28866ac6e6 | |||
| 5cb97edf39 | |||
| 363b1ba21a | |||
| febf9f6f3c | |||
| 05e93b2f5c | |||
| 0a6adff5cb | |||
| 28e18d0258 | |||
| 09be4de0eb | |||
| 1309afec93 | |||
| c1f7e06b22 | |||
| 377e02c943 | |||
| 5c6aaf53cf | |||
| f1b74a35ef | |||
| 6d9ad62a8f | |||
| 783a7f87bb | |||
| ca0b5ff813 | |||
| 2069cd5f25 | |||
| 771a053eea | |||
| 7fc3d46b9a | |||
| 591f34070d | |||
| 06155d4bcc | |||
| 8a87c973d2 | |||
| e2536f14f3 | |||
| 5b217c1f78 | |||
| b7eff5db66 | |||
| d73d47c081 | |||
| d57c9dc798 | |||
| fc92db9f5b | |||
| 8bdd9f5ce0 | |||
| 96adb172eb | |||
| b5d3f6ec73 | |||
| f62b826c64 | |||
| 99d0670ebe | |||
| d9280773aa | |||
| 160b5b5139 | |||
| b73607ed02 | |||
| 48c52497c2 | |||
| 425c441470 | |||
| e2c2bb41b7 | |||
| d1442848c4 | |||
| 0e65dfee64 | |||
| fc5fe11590 | |||
| 199553f91a | |||
| d972a2d135 | |||
| 80d188efc1 | |||
| 82b754661d | |||
| 72f02b866a | |||
| 170bc5fde2 | |||
| c6595d2cf0 | |||
| 2adcbb7387 | |||
| 3adb5a7018 | |||
| afc98191a9 | |||
| f84011d9ef | |||
| 00a4a5f6ee | |||
| e08c0bc569 | |||
| 600df5b817 | |||
| 98612b2746 | |||
| 885e0b66a3 | |||
| 64c614b99a | |||
| eac9f0fca6 | |||
| 0dfa6dcb27 | |||
| 114fae84aa |
8
.gitignore
vendored
8
.gitignore
vendored
@ -1 +1,9 @@
|
||||
# pi session transcripts
|
||||
.pi-sessions/
|
||||
|
||||
# plugin runtime data (created by defaultPath())
|
||||
.dsh-memory/
|
||||
|
||||
# dev-only symlinks to the globally installed dsh packages.
|
||||
# `scripts/link-dsh.sh` recreates them; the plugin itself has no dependencies.
|
||||
node_modules/
|
||||
|
||||
784
README.md
784
README.md
@ -1,33 +1,777 @@
|
||||
# HologramAgent-DSH-Memory-Port
|
||||
|
||||
评估 HologramAgent 三层记忆系统移植为 dsh 插件的可行性(调研/评估,不产出实现)
|
||||
把 HologramAgent 的记忆系统移植到 dsh 插件。本仓分两部分:
|
||||
|
||||
## 报告
|
||||
1. **调研报告**(已交付)
|
||||
- [`docs/feasibility.md`](docs/feasibility.md) — 三层记忆移植可行性评估(结论:**只能做子集**,L2+L3 可移植,L1 抓不住)
|
||||
- [`docs/shadow-context.md`](docs/shadow-context.md) — 影子上下文方案核实(结论:路线② preset 可行,路线① 只是看着可行)
|
||||
2. **MVP 阶段 A 实现**(本文件以下内容)— 1–2 天切片:块能进 dsh、能被召回、能进 prompt,**不引入 embedding**
|
||||
|
||||
→ [`docs/feasibility.md`](docs/feasibility.md)
|
||||
---
|
||||
|
||||
## 结论摘要
|
||||
# dsh-memory(阶段 A)
|
||||
|
||||
**只能做子集**(四选一中的第三档)。
|
||||
HologramAgent-style **block memory** for [dsh](https://github.com/deepseek-ai/deepseek-harness),
|
||||
as a dsh plugin. This is **phase A** of the MVP described in
|
||||
[`docs/feasibility.md`](docs/feasibility.md) §7.1 — the smallest slice that proves
|
||||
the whole chain works:
|
||||
|
||||
| 层 | 可移植性 | 瓶颈 |
|
||||
```
|
||||
commit a block → store it durably → recall it → get it into the model's prompt
|
||||
```
|
||||
|
||||
Deliberately **not** included: embedding providers, vectors, the L1 eviction flow,
|
||||
media blocks. Each is blocked or out of scope for a 1–2 day slice; see
|
||||
[What this slice does not do](#what-this-slice-does-not-do).
|
||||
|
||||
---
|
||||
|
||||
## ★ Read this before touching injection
|
||||
|
||||
dsh has two injection hooks that look interchangeable and are not. Getting this
|
||||
wrong silently corrupts the conversation log.
|
||||
|
||||
| hook | receives this turn's input? | durable side effect |
|
||||
|---|---|---|
|
||||
| L1 context 热上下文 | ≈20% | dsh 的上下文压缩是「摘要替换区间」,不是「按 query 相关性淘汰 + 迁移到冷层 + 按原时间戳迁回」;`CompactionTrigger` 接口拿不到 query,`MessageBase` 无时间戳 |
|
||||
| L2 coldcontext 冷文档 | ≈80% | 存储/召回/注入都能落,缺「回收站式 `Peek→回灌→Remove`」 |
|
||||
| L3 stablemem 图记忆 | ≈85% | 结构完全可映射(`node:sqlite` 实测可用),受限于无 embedding 服务 |
|
||||
| `ctx.systemPrompt.context({ text })` | **No** — `AssembleContext` is `{scope, signal}` + a merged-in `agent` | appends a runtime-context snapshot |
|
||||
| `ctx.on('agent/pre-step')` | **Yes** — payload carries `decision.messages` | **every message you return is appended as a durable `user/message`** |
|
||||
|
||||
**可整体搬运的资产**:`MemoryBlock` 值对象、确定性 ID 派生、仲裁/拒答/符号检索算法——这三块与宿主无关。
|
||||
Evidence, verified in the installed harness (not inferred):
|
||||
|
||||
**推荐落地范围**:L2 + L3 子集,L1 交给 dsh 自己的 compaction。
|
||||
工作量(含判据与实测)**36–53 人天**;压到 MVP(L3 + 工具 + 注入面)**19–25 人天**。
|
||||
- `dsh-system-prompt/lib/types/index.d.ts:37-44` — `AssembleContext` has only `scope` and `signal`
|
||||
(`agent` is added by `dsh-agent`'s declaration merging; still no messages).
|
||||
- `dsh-agent-loop/lib/index.js:902-934` — `preStep()` claims the inbox, calls
|
||||
`systemPrompt.assemble()`, then runs the `agent/pre-step` waterfall with those messages.
|
||||
- `dsh-agent-loop/lib/index.js:1066-1073` — the loop then does
|
||||
`session.append('user/message', message, { surfaceOp: 'append' })` **for each returned message**.
|
||||
|
||||
**最小第一步切片**(1–2 人天):插件骨架 + `Service` 注册 + `node:sqlite` 建 `memory_blocks`
|
||||
+ FTS5 召回 + `agent/pre-step` 注入 + 一个最小工具,**全程不引入 embedding 依赖**。
|
||||
详见报告 §7。
|
||||
**Consequences, and how this plugin uses them:**
|
||||
|
||||
## 取证方式
|
||||
- The **input-independent** index (a block count, "memory exists, here's the tool")
|
||||
goes through `systemPrompt.context`. It cannot see the current question, so it
|
||||
must not pretend to be a recall result.
|
||||
- **Per-turn recall** goes through `agent/pre-step`, because that is the only hook
|
||||
that sees the input. Recall is `prepend: true` so it runs before listeners that
|
||||
may want the final word.
|
||||
- Because pre-step output is **durable**, injected recall becomes part of the
|
||||
conversation history. That is acceptable here (it is honest: the model saw it),
|
||||
but it means this plugin is **not** a shadow-context mechanism. For that
|
||||
distinction see [`docs/shadow-context.md`](docs/shadow-context.md) §1.3.
|
||||
|
||||
- HologramAgent 源码与 `docs/zh/` 设计文档:**全程只读,未做任何修改**
|
||||
- dsh 接口:只读 `lib/types/*.d.ts`(权威接口),不从 `lib/*.js` 实现倒推
|
||||
- 关键环境结论均有实测(`node:sqlite` + FTS5、`onnxruntime-node` 缺失、`libonnxruntime.so` 位置)
|
||||
- 每条断言标注文件路径与符号名;仅三处标注「推断」:`turn`≈`FrameTS` 映射、`Intl.Segmenter` 中文分词质量、人天估算
|
||||
### ★ A real batch is not just the question
|
||||
|
||||
`decision.messages` is the batch admitted for the step, and in a real dsh profile
|
||||
the human question is **followed by other user-role messages**:
|
||||
|
||||
```
|
||||
[ user question ][ runtime-context snapshot ][ skill catalog reminder ]
|
||||
```
|
||||
|
||||
All three carry `role: 'user'`. So "use the last user message as the recall
|
||||
query" returns the **skill catalog**, and every real turn recalls nothing.
|
||||
|
||||
This plugin's first release had exactly that bug. It was found by running the
|
||||
plugin in a real headless profile, not by the test suite — the unit tests built
|
||||
2-message batches, so they never saw it (all checkpoints were green while
|
||||
auto-recall was 100% dead).
|
||||
|
||||
`lastUserText` therefore filters by **`source.kind`**, not by role:
|
||||
|
||||
**Allowed** (the recall query may come from these):
|
||||
|
||||
| kind | what |
|
||||
|---|---|
|
||||
| `user` | **every human prompt**, including remote/browser ones |
|
||||
|
||||
**Everything else is injected.** The rule is an allowlist over `source.kind`,
|
||||
so an unlisted — including a future — kind fails closed.
|
||||
|
||||
Two facts make that strict allowlist safe:
|
||||
|
||||
1. **`user` is the kind for *all* human prompts, local and remote.** A browser
|
||||
prompt arrives under a different source *key* (`user-rpc`) but the same
|
||||
*kind*:
|
||||
```ts
|
||||
// dsh-api-session-controller/lib/types/types.d.ts:395-400
|
||||
'user-rpc': { kind: 'user'; rpcId: SessionRequestId;
|
||||
clientTimeZone?: string; principal?: Principal }
|
||||
```
|
||||
So filtering on `kind` (not on source key, and not on "has no extra fields")
|
||||
cannot lock out web users. `G6b` pins this.
|
||||
2. **Injected context always declares its own kind.** Examples verified as
|
||||
literal declarations in the installed harness:
|
||||
|
||||
| kind | source |
|
||||
|---|---|
|
||||
| `runtime-context` | `dsh-agent-loop/lib/types/runtime-context.d.ts:13` |
|
||||
| `skill-catalog` | `dsh-tool-skill/lib/types/index.d.ts:29` |
|
||||
| `skill-invocation` | `dsh-skill/lib/types/index.d.ts:131` |
|
||||
| `schedule` | `dsh-schedule/lib/types/runtime.d.ts:5` |
|
||||
| `agent-message` | `dsh-subagent/lib/types/continuation-messages.d.ts:37` |
|
||||
| `subagent-settled` | `dsh-subagent/lib/types/continuation-messages.d.ts:38` |
|
||||
| `agent-instructions` | `dsh-agent-instructions/lib/types/state.d.ts:27` |
|
||||
| `time-context` | `dsh-time-context/lib/types/index.d.ts:14` |
|
||||
| `tool-goal`, `tool-jobs` | `dsh-tool-goal/…/index.d.ts:12`, `dsh-tool-jobs/…/index.d.ts:16` |
|
||||
| `user-approval` | `dsh-user-approval/lib/types/index.d.ts:14` |
|
||||
| `plan-mode`, `ptc-mode` | `dsh-plan-mode/…/index.d.ts:31`, `dsh-tools/lib/types/ptc.d.ts:14` |
|
||||
| …and more | see the `G6` table |
|
||||
|
||||
**Scope of the test table — read this before trusting it.** `G6` covers *every
|
||||
user-role injected kind declared in the currently installed harness* (23 of
|
||||
them). It is **not** a claim of exhaustiveness: the full `MessageSourceMap`
|
||||
surface is larger and grows with each release. That is fine, because the
|
||||
allowlist fails closed, so an unlisted kind is safe by construction. What `G6`
|
||||
actually buys is: a rename in the harness breaks a test here rather than
|
||||
silently killing recall in production.
|
||||
|
||||
`G7` separately pins that a message with **no `source` at all** yields `''`.
|
||||
Reachability, stated precisely: currently **unreachable** — `assertMessageEventShape`
|
||||
requires a non-empty `source.kind` and runs on both the append path
|
||||
(`dsh-session/lib/index.js:1089`) and the seed/restore path (`:1165`). It is
|
||||
therefore defense in depth against a future format change, not a live bug.
|
||||
|
||||
Measured effect (4 seeded notes, three batch shapes):
|
||||
|
||||
| batch shape | old rule (last role=user) | fixed rule (last human kind) |
|
||||
|---|---|---|
|
||||
| 2-part (the old test shape) | 1 hit | 1 hit |
|
||||
| 3-part (+runtime-context) | **0 hits** | 1 hit |
|
||||
| 4-part (+runtime-context +skill-catalog) | **0 hits** | 1 hit |
|
||||
| **total** | **1 / 3** | **3 / 3** |
|
||||
|
||||
Guarded by these tests in `test/plugin.test.js`:
|
||||
|
||||
| test | what it pins |
|
||||
|---|---|
|
||||
| regression: extraction | the question, not the injection, is returned |
|
||||
| regression: end-to-end | a real 3-part batch actually recalls (this was 0 hits) |
|
||||
| regression mutation | reintroducing "last role=user" drops hits to **0** |
|
||||
| **G6** | all 23 user-role injected kinds in the installed harness are rejected (parameterised over literal declarations) |
|
||||
| **G6 mutation** | "role is user ⇒ accept" must fail on **all 23** |
|
||||
| **G6b** | a `user-rpc` prompt (`kind: 'user'` + `rpcId`/`principal`) **is** accepted — the allowlist does not lock out remote users |
|
||||
| **G7** | a message with no `source` yields `''` |
|
||||
| **G7 mutation** | dropping the guard admits it, so the guard is load-bearing |
|
||||
|
||||
### ★ The lesson: a 2-message batch is a fake batch
|
||||
|
||||
The bug above shipped with **every checkpoint green**. The reason is narrower than
|
||||
"tests were missing":
|
||||
|
||||
> `V3b` built its batch as `[question]` — or `[question, injected]`. A real dsh
|
||||
> step appends injected context *after* the question, so the very shape that made
|
||||
> the bug invisible was the shape the test chose.
|
||||
|
||||
Hence a rule for anything added from here on: **any test that exercises
|
||||
`agent/pre-step` must build its batch through `realBatch()`**, which always appends
|
||||
at least a `runtime-context` and a `skill-catalog` message after the question.
|
||||
A batch of one human message proves the hook fires; it does not prove the hook
|
||||
reads the right thing. `V3b` is retained (it still pins injection attribution)
|
||||
but is flagged `V3b⚠` as a known blind spot.
|
||||
|
||||
There is **no** hook for "the context is about to overflow, please evict
|
||||
something", and no waterfall that can rewrite a loop-built request
|
||||
(`llm/stream` receives a deep-frozen `GenerateOptions` for loop requests —
|
||||
`dsh-llm/lib/types/index.d.ts:37-42`).
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
The plugin brings no dependency of its own: everything it imports at runtime is a
|
||||
`@deepseek-ai/*` **peer** that ships with the dsh install on the machine.
|
||||
`npm install` does **not** deliver those peers (three of the four are declared
|
||||
optional), so on its own it leaves a tree that cannot even load the plugin — link
|
||||
them first, per
|
||||
[Prerequisite: link the harness packages first](#prerequisite-link-the-harness-packages-first).
|
||||
|
||||
```bash
|
||||
# from this repo
|
||||
npm install
|
||||
```
|
||||
|
||||
The plugin needs `node:sqlite` (built into Node ≥ 22.5). On Node 22.23 it works
|
||||
behind an `ExperimentalWarning` only — no `--experimental-sqlite` flag.
|
||||
|
||||
Mount it in a dsh composition:
|
||||
|
||||
```yaml
|
||||
- id: memory
|
||||
name: dsh-memory
|
||||
config:
|
||||
path: /path/to/memory.db # optional; defaults to <cwd>/.dsh-memory/memory.db
|
||||
topK: 3 # optional; recall hits injected per turn
|
||||
inject: true # optional; false = tool only, no auto-recall
|
||||
toolName: stablemem_commit # optional; must be unique per tool layer
|
||||
```
|
||||
|
||||
## Use
|
||||
|
||||
The model gets one tool:
|
||||
|
||||
```
|
||||
stablemem_commit({ text, source?, scene? })
|
||||
```
|
||||
|
||||
and, once at least one block exists, a constant prompt context line telling it
|
||||
that durable memory is available.
|
||||
|
||||
Recall runs automatically each turn: the last user message is used as the query,
|
||||
FTS5 + exact-symbol pinning selects up to `topK` blocks, and they are injected as
|
||||
one attributed user message:
|
||||
|
||||
```
|
||||
[Memory · dsh-memory] Recalled from durable block memory — these are PAST notes, not the current conversation.
|
||||
[1] (exact 1.01) 值班室分机号 4324,值班人 老周
|
||||
```
|
||||
|
||||
Programmatic use (another plugin, or a preset subtree):
|
||||
|
||||
```js
|
||||
await ctx.stablemem.commit({ text: 'etcd 集群端口 2379', source: 'ops' })
|
||||
await ctx.stablemem.recall('etcd 端口', { topK: 5 }) // both are async since phase B
|
||||
await ctx.stablemem.recall('etcd 端口', { topK: 5, signal }) // pass the turn's signal
|
||||
ctx.stablemem.link({ sourceKind: 'block', sourceId: a, targetKind: 'block', targetId: b, type: 'supersedes' })
|
||||
```
|
||||
|
||||
> `recall(query, 5)` used to work and silently stopped meaning anything when the
|
||||
> second parameter became an options object — a number is not `{topK}`, so the
|
||||
> default was used. Both `commit` and `recall` are `async` now; omitting `await`
|
||||
> on `commit` still writes today only because the no-embedder path has no `await`
|
||||
> before `putBlock`, which is exactly the fragile assumption W6 guards.
|
||||
|
||||
### Phase B · group A — the async seam
|
||||
|
||||
`recallBlocks` stays **synchronous and unmodified**; vectors get a separate async
|
||||
wrapper beside it:
|
||||
|
||||
```js
|
||||
recallBlocks(store, query, opts) // sync, unchanged, no embedder
|
||||
recallBlocksHybrid(store, embedder, query, opts) // async wrapper
|
||||
```
|
||||
|
||||
Why the split rather than "make recallBlocks async": every existing caller keeps
|
||||
calling a sync function, so the 9 test call sites and the whole phase-A surface
|
||||
need no edit, and the golden fixture proves the sync path did not move.
|
||||
|
||||
With no embedder (or a failing one) the wrapper is exactly `recallBlocks`, and it
|
||||
reports why it degraded on the result array itself:
|
||||
|
||||
```js
|
||||
const hits = await recallBlocksHybrid(store, null, q)
|
||||
hits.degraded // 'no_embedder' | 'embed_error' | 'vector_ready_unused'
|
||||
```
|
||||
|
||||
`degraded` is non-enumerable, so it does not appear in `JSON.stringify`,
|
||||
`Object.keys`, or deep-equality against phase-A output. It never throws — a dead
|
||||
embedding service must not take recall down (§0.4).
|
||||
|
||||
`'vector_ready_unused'` is deliberate naming: group A computes the query vector but
|
||||
does **not** feed it into scoring yet, so calling that state `'hybrid'` would be a
|
||||
lie that outlives its reason. Group V renames it when vectors actually participate.
|
||||
|
||||
> Because `degraded` is non-enumerable it is invisible to any serialization. That
|
||||
> makes it fine for tests and useless for the "degradation must be visible"
|
||||
> requirement — group D therefore renders counters into the prompt text, not onto
|
||||
> an object property.
|
||||
|
||||
Guarded by `test/phase-b-a.test.js` against
|
||||
`test/fixtures/golden-phase-a.json`, comparing **id, order, score (exact, not
|
||||
approximate), the `exact` flag, and the `why` string** across every corpus block
|
||||
× query × topK case in that fixture. A tolerance-based comparison would let a
|
||||
scoring change through, which is the one thing this group exists to stop.
|
||||
|
||||
> The fixture itself defines how many cases exist — read the file, not this
|
||||
> sentence. Counting them here would reintroduce exactly the unguarded number the
|
||||
> rest of this section deliberately avoids.
|
||||
|
||||
> **A trap this group found:** making `Svc.recall()` async silently disarmed a
|
||||
> regression mutation — `await`-ing was missing, `undefined.length` is
|
||||
> `undefined`, and `undefined < undefined` is `false`, so an assertion comparing
|
||||
> hit counts *passed while measuring nothing*. Both that test and checkpoint V3c
|
||||
> now `await`. If a future change makes anything else async, grep for `.length`
|
||||
> on a value that may have become a Promise.
|
||||
|
||||
### Phase B · group G — guards
|
||||
|
||||
`test/phase-b-g.test.js` (behavioural) and `test/phase-b-g-static.test.js`
|
||||
(source-text) pin the properties that keep vectors from damaging phase A:
|
||||
|
||||
| # | guard | mutation that must turn it red |
|
||||
|---|---|---|
|
||||
| G2 | no embedder ⇒ **zero** network calls (global `fetch` replaced with a tripwire) | add a `fetch` to the no-embedder branch |
|
||||
| G2b | `lib/` imports only `node:` builtins + dsh peers | add `onnxruntime-node` |
|
||||
| G3 | `stablemem_commit` still writes when the embedder throws; symbol recall still finds it | make commit await the embedder and reject |
|
||||
| G4 | only the `text` modality; no media columns exist | add a `data BLOB` column |
|
||||
| G5 | transport identifiers (`fetch(`, `Authorization`, `Bearer`, `http://`, `model_dir`, `api_key`) may appear **only** under `lib/embed/` | put an endpoint literal in `recall.js` |
|
||||
| G2† | **every** early-degradation path of `recallBlocksHybrid` is network-free (6 paths, data-driven) | add a `fetch` to any one of them |
|
||||
| G2†b | the fingerprint check runs **before** `embedText` | move the call earlier |
|
||||
|
||||
Two things this group taught us, both about guards rather than about vectors:
|
||||
|
||||
1. **A source-scanning guard must not import the code it scans.** G2b/G5 first
|
||||
lived beside the behavioural guards, which import `lib/index.js`. A broken
|
||||
`lib/` then crashed the whole file, so the report was "load failed" instead of
|
||||
"you added a disallowed dependency". They now live in
|
||||
`test/phase-b-g-static.test.js`, which imports nothing from `lib/` and still
|
||||
names the offender when `lib/` cannot load at all.
|
||||
2. **Comment-aware scanning or none.** G5's first version flagged
|
||||
`"no endpoint"` in a *comment* and `"edge endpoint"` in `store.js`. A guard
|
||||
that cries wolf gets disabled, which is worse than not having it. It now
|
||||
strips comments before scanning.
|
||||
|
||||
### Phase B · group H — HTTP embedding adapter
|
||||
|
||||
`lib/embed/http.js` is the **only** file allowed to know a transport (enforced by
|
||||
`G5`). Protocol is byte-compatible with HologramAgent's `http` provider
|
||||
(`internal/memory/vector/http_embedder.go:72-87`), so an existing HA sidecar works
|
||||
without a shim:
|
||||
|
||||
```jsonc
|
||||
// request
|
||||
{ "model": "…", "modality": "text", "side": "query", "text": "…" }
|
||||
// response — either shape accepted
|
||||
{ "embedding": [ … ] } { "data": [ { "embedding": [ … ] } ] }
|
||||
```
|
||||
|
||||
Enable it:
|
||||
|
||||
```yaml
|
||||
- id: memory
|
||||
name: dsh-memory
|
||||
config:
|
||||
embed:
|
||||
endpoint: http://127.0.0.1:18999/embed
|
||||
dimension: 2048
|
||||
model: jina-v5
|
||||
# apiKey / fingerprint / timeoutMs optional
|
||||
```
|
||||
|
||||
Omit `embed` entirely to run symbol-only, exactly as phase A did.
|
||||
|
||||
**Fingerprint: `http:<model>:<dim>:<sha256(normalised endpoint)[:8]>`**
|
||||
|
||||
HA derives `http:<model>:<dim>` (`http_embedder.go:99`) — model and dimension
|
||||
only. That is unsafe once the endpoint is configurable: two deployments of the
|
||||
same model name at the same dimension produce **the same fingerprint but
|
||||
different vectors**, so their blocks compare silently and wrongly. The endpoint
|
||||
hash closes that. It is computed **once at construction** and reused, because the
|
||||
same string must reach both tables:
|
||||
|
||||
> **Ticket ② constraint.** `memory_blocks.fingerprint` and
|
||||
> `graph_centroid.fingerprint` must be **byte-identical** for the same space. HA
|
||||
> looks the centroid up by fingerprint (`block_recall.go:92`), so two spellings
|
||||
> make the lookup miss with no error — centering just never applies and scores
|
||||
> quietly degrade.
|
||||
>
|
||||
> This is enforced **structurally**, not by discipline: `StableMem.space()` is the
|
||||
> only source of the string, `commit()` stamps `space().fingerprint`, and
|
||||
> `saveSpaceCentroid()` takes no fingerprint argument at all — so a caller cannot
|
||||
> supply a second spelling. The set assertion is
|
||||
> `SELECT DISTINCT fingerprint FROM memory_blocks` ≡ the centroid keys.
|
||||
> Mutations verified: prefixing the centroid key, and deriving it from the block
|
||||
> text, each turn `ticket②-strong` red.
|
||||
|
||||
> **Ticket ③ boundary.** A space with embedded blocks but no centroid row reports
|
||||
> `dim: null`, never `0` — zero reads like a real answer. `loadCentroid()` returns
|
||||
> `undefined` for a missing row rather than throwing: HA treats a missing or
|
||||
> under-sampled centroid as normal degradation (`centroid_store.go:144-184`), not
|
||||
> a recall error. The dimension comes from centroid metadata, never from
|
||||
> `length(vector)`. The four-state counters belong to groups F/D.
|
||||
|
||||
Endpoint normalisation (before hashing, so cosmetic differences don't each cause a
|
||||
full re-embed): trim, lowercase scheme+host, drop trailing `/`, drop an explicit
|
||||
default port. It deliberately does **not** reorder query strings or resolve `..`:
|
||||
those differences are usually real.
|
||||
|
||||
### Write path
|
||||
|
||||
`commit()` embeds before writing, which adds four obligations the synchronous
|
||||
path did not have. Each has a one-line mutation that turns its test red:
|
||||
|
||||
| obligation | behaviour | why |
|
||||
|---|---|---|
|
||||
| **W4** dimension | a vector whose length ≠ `space().dimension` is treated as an embed failure — the block lands with `vector: ''` | recall already skips mismatched vectors, so storing one yields a block that exists, looks fine, and can never be retrieved |
|
||||
| **W5** cancellation | `exec.signal` reaches `embedText`, where it is fused with the adapter's own timeout; aborting stores the block with no vector | otherwise a cancelled turn holds the call for the adapter's full timeout, and `isConcurrencySafe` siblings queue behind it. **W5 only covers the pipeline — the adapter side is W9's job** |
|
||||
| **W6** result shape | with no embedder, `commit()` returns the same `{id, created}` as `putBlock` and the block is immediately FTS-recallable | nine call sites relied on "no embedder means it happens synchronously" — true today, silently false the moment an `await` is added |
|
||||
| **W7** empty fingerprint | checked **before** embedding, so an unnamed space costs zero round-trips | the previous order embedded first and threw afterwards, paying for a result it could never label |
|
||||
|
||||
> On **W5**, `isError` is not the assertion: after a body returns, the tool
|
||||
> registry re-checks the signal and replaces a success with its canonical abort
|
||||
> result (`dsh-tools/lib/index.js:3408`). What this plugin controls is the *side
|
||||
> effect* — the block is written — so that is what the test asserts.
|
||||
>
|
||||
> **★ W5 initially passed while cancellation was completely broken.** It used a
|
||||
> hand-written slow embedder that registered its own `abort` listener, so it
|
||||
> tested the pipeline against a double. The real adapter's `embedText(text)` took
|
||||
> one parameter, so `embedText(text, signal)` silently dropped the signal: a
|
||||
> cancelled turn ran the full timeout and then stored the vector anyway — with 99
|
||||
> tests green. W9 now uses a **real HTTP server and the real
|
||||
> `createHttpEmbedder`**, and pins both halves of the contract:
|
||||
>
|
||||
> | test | asserts | mutation |
|
||||
> |---|---|---|
|
||||
> | **W9** | a caller abort interrupts a real request in < 500 ms | make `embedText` single-parameter *and* drop the fuse ⇒ red |
|
||||
> | **W9b** | the adapter's own timeout still fires when a caller signal is present | drop the timeout from the fuse ⇒ red |
|
||||
> | **W9c** | end-to-end: aborted tool call still writes the block, `vector: ''`, `code: 'ABORTED'` | as W9 |
|
||||
>
|
||||
> Note the mutation had to be *clean*: replacing only the signature left a
|
||||
> dangling `signal` reference that threw a `ReferenceError`, which W9 read as
|
||||
> "rejected quickly" and passed. A mutation that fails for the wrong reason is
|
||||
> not evidence.
|
||||
|
||||
### Read-path cancellation (W10) and visible degradation (W11)
|
||||
|
||||
**W10** — the write path was fixed in W9; the read path had the same defect and
|
||||
nobody had looked. `recallBlocksHybrid` called `embedText(text)` with one
|
||||
argument, and neither `StableMem.recall()` nor `contextForStep()` took a signal —
|
||||
even though the `agent/pre-step` payload carries one
|
||||
(`dsh-agent/lib/types/runtime-types.d.ts:304-310`). Measured on the real adapter
|
||||
with a hanging server: **2004 ms** for an abort issued at 50 ms; at the shipped
|
||||
30 s timeout, *every pre-step could block for 30 s with cancellation having no
|
||||
effect*.
|
||||
|
||||
The signal now flows `agent/pre-step payload.signal → contextForStep → recall →
|
||||
recallBlocksHybrid → embedText`, and W10 measures the fix through the real hook
|
||||
chain (not by calling the seam directly): **54 ms**.
|
||||
|
||||
| test | asserts | mutation |
|
||||
|---|---|---|
|
||||
| **W10** | aborting a pre-step returns in < 500 ms, degrade reported | drop `options.signal` at `embedText` ⇒ red, **30001 ms** |
|
||||
| **W10b** | the same at the seam, so a hook refactor cannot hide a regression | as above |
|
||||
| **W11** | a degraded recall puts a readable notice in the rendered text | remove the notice line ⇒ red |
|
||||
| **W11d** | degraded **and** zero hits still injects the notice (no entries) | restore either short-circuit ⇒ `W11d` red |
|
||||
| **W11e** | default config (no embedder) injects **nothing** on ordinary turns | make `no_embedder` speak again ⇒ `W11e` red |
|
||||
| **W12** | `aborted` and `embed_error` carry different labels and different wording | collapse the two labels ⇒ red |
|
||||
| **W11f** | no mode promises "matches below" when there is nothing below; every mode points at the entries when there are | drop the `aborted` split ⇒ red; drop the `default:` split ⇒ red |
|
||||
| **W11b** | a healthy recall renders no notice | — |
|
||||
| **W11c** | end-to-end: aborted pre-step text still carries the hit *and* the notice | remove the notice line ⇒ red |
|
||||
|
||||
**W11e — a standing fact must not be repeated every turn.** W11d made
|
||||
"degraded + zero hits" speak up, which is right for a *per-turn event* and wrong
|
||||
for a *standing configuration fact*. Without an embedder is the shipping default,
|
||||
and `renderIndex()` already tells the model every turn that block memory exists.
|
||||
Measured before the fix, phase-A default config, ten ordinary turns
|
||||
("今天天气怎么样", "你好", "谢谢", …):
|
||||
|
||||
```
|
||||
10 injected, 2130 chars, mean 213 chars/turn
|
||||
```
|
||||
|
||||
The cost is not really the tokens — it is that a constant, entry-less memory block
|
||||
teaches the model to skip the section, defeating the purpose of W11 for the
|
||||
degradations that matter. W11d and W11e are mutual guards: a per-turn event
|
||||
speaks, a standing state stays silent.
|
||||
|
||||
**Mutation discipline (learned four times over).** A mutation must modify a file
|
||||
under `lib/`. A test that builds its own stand-in function, patches a local arrow,
|
||||
or otherwise asserts on something it just wrote is a *self-test*: it cannot fail
|
||||
when `lib/` regresses. Two such tests (formerly `W11d†a`/`W11d†b`) were green
|
||||
under both real mutations while `W11d` itself went red — they have been deleted.
|
||||
The surviving guards (`W11d†`, `W11d†ctx`, `W11e†`) apply the mutation to a
|
||||
**temporary copy** of `lib/`, import that copy, and assert the behaviour flips.
|
||||
If a mutation anchor no longer matches they **fail loudly** rather than quietly
|
||||
passing, so a refactor cannot silently disarm them.
|
||||
|
||||
> The first version of these guards patched `lib/` **in place** and restored it.
|
||||
> That raced every other test file: `node --test` runs files in parallel
|
||||
> processes, so another suite could import the module inside the mutated window.
|
||||
> It produced roughly a **1-in-30** spurious failure whose symptom looked like a
|
||||
> flaky assertion rather than a harness bug — the same class of "sometimes green"
|
||||
> that is worst to debug. Mutating shared state is never safe to run concurrently
|
||||
> with the code under test, so the mutation now happens on a copy. Verified with
|
||||
> 30 consecutive full runs: 0 failures.
|
||||
|
||||
> Why the notice exists at all: `hits.degraded` is non-enumerable (group A needs
|
||||
> the golden comparison undisturbed), so it is invisible to `JSON.stringify`,
|
||||
> `Object.keys`, and every serialised log. **A test asserting that property proves
|
||||
> nothing about what the model receives.** The rendered text is the only channel,
|
||||
> so "degradation is visible" has to be asserted on the text.
|
||||
|
||||
### Building the centroid (W8)
|
||||
|
||||
`rebuildCentroid(fingerprint)` is the entry point that had no caller: without it
|
||||
blocks carried vectors while `graph_centroid` stayed empty forever, `loadCentroid`
|
||||
always missed, and centering silently never applied.
|
||||
|
||||
Ported from HA's `RebuildCentroid` (`centroid_store.go:246-320`): only rows from
|
||||
that space, a mismatched dimension is skipped rather than coerced, the mean is
|
||||
accumulated over L2-normalised vectors, and the mean's norm decides whether to
|
||||
centre at all (`MEAN_THRESHOLD = 0.6`, `centroid.go:87`).
|
||||
|
||||
**Who triggers a rebuild is still an open C-group decision** (post-commit ratio vs
|
||||
lazy pre-step check vs an explicit tool). What exists now is the mechanism plus a
|
||||
test corpus that sits *between* 0.6 and 0.95 — collinear vectors give mean length
|
||||
1.0 and would pass any threshold, which is exactly how the first version of that
|
||||
test failed to distinguish the mutation.
|
||||
|
||||
Failures are **hard at the adapter** and **soft at the recall layer**:
|
||||
|
||||
| layer | behaviour |
|
||||
|---|---|
|
||||
| adapter | non-2xx / no embedding array / wrong dimension / oversized body / timeout ⇒ throws |
|
||||
| `recallBlocksHybrid` | catches, sets `degraded: 'embed_error'`, answers from FTS5 |
|
||||
|
||||
A wrong dimension is never truncated or padded — a silently mis-sized vector
|
||||
produces a plausible-looking but meaningless score (`block_recall.go:135-138`).
|
||||
|
||||
### Schema migration
|
||||
|
||||
`PRAGMA user_version` gates the upgrade (currently `0 → 1`).
|
||||
|
||||
Phase A shipped only `CREATE TABLE IF NOT EXISTS`, which is a **silent no-op** on
|
||||
an existing table. A database created by the phase-A build therefore opened
|
||||
without error, recalled fine through FTS, and then threw on the **first commit**:
|
||||
|
||||
```
|
||||
PUTBLOCK THREW: table memory_blocks has no column named vector
|
||||
```
|
||||
|
||||
The memory store became read-only with no diagnostic at load time — a worse
|
||||
failure shape than a loud error, because nothing looks wrong until someone tries
|
||||
to write.
|
||||
|
||||
The migration probes `pragma_table_info` instead of guessing from the version
|
||||
number (a version-0 database created by a crashed phase-B build may already have
|
||||
the columns), then `ALTER TABLE ... ADD COLUMN` with `DEFAULT ''` — the same
|
||||
"not embedded yet" sentinel `putBlock` uses.
|
||||
|
||||
`graph_centroid` needs care: phase A created it with different columns, and the
|
||||
DDL has **already run** by the time the migration sees the table, so
|
||||
`CREATE TABLE IF NOT EXISTS` was a no-op. Dropping the old table without
|
||||
rebuilding produces the narrowest failure in the codebase — the store opens
|
||||
cleanly, stamps `user_version = 1`, accepts `putBlock`, and throws
|
||||
`no such table: graph_centroid` only on the first `saveCentroid`. The rebuild
|
||||
therefore happens **inside** the migration (`DDL_CENTROID`).
|
||||
|
||||
A populated old centroid table is renamed to `graph_centroid_phase_a_<timestamp>`
|
||||
rather than dropped (data loss) or treated as fatal (the whole database would
|
||||
refuse to open, for a shape the plugin could not produce by normal use). Startup
|
||||
continues, the old rows stay inspectable with `sqlite3`.
|
||||
|
||||
| test | what it pins | mutation that must turn it red |
|
||||
|---|---|---|
|
||||
| MIG1 | phase-A `memory_blocks` gains both columns; legacy block readable, FTS-matching, and writable | comment out `ADD COLUMN vector` |
|
||||
| MIG2 | a fresh database starts at version 1 | skip `migrate()` |
|
||||
| MIG3 | phase-A database can `saveCentroid` + `loadCentroid` after migration | drop the centroid rebuild |
|
||||
| MIG3b | a populated phase-A centroid is renamed aside, not fatal, not lost | revert to `throw` |
|
||||
|
||||
Verified end to end: a real phase-A database opens, reports `user_version = 1`,
|
||||
accepts a commit, and its legacy block is still recallable.
|
||||
|
||||
---
|
||||
|
||||
## Schema
|
||||
|
||||
Three tables, mirroring HologramAgent's (`internal/memory/graph.go:2577`, `:320`,
|
||||
`centroid_store.go:58`), restricted to the text modality:
|
||||
|
||||
| table | purpose | notes |
|
||||
|---|---|---|
|
||||
| `memory_blocks` | the block payload | `text_content` is the body; `source` / `scene` / `semantic_type` mirror HA's columns |
|
||||
| `memory_block_edges` | structural + semantic edges | unique per `(source_kind, source_id, target_kind, target_id, type)` |
|
||||
| `graph_centroid` | vector-space centering | **created empty** — phase B writes it; creating it now avoids a migration |
|
||||
| `memory_blocks_fts` | FTS5 index over `text_content` | `unicode61 remove_diacritics 2` |
|
||||
|
||||
`memory_blocks.id` is content-addressed
|
||||
(`blk_` + `sha256(text)[:12]`, ported from `distill.BlockID`,
|
||||
`internal/memory/distill/blocks.go:99`), so re-committing identical text is
|
||||
idempotent and never blanks existing metadata.
|
||||
|
||||
---
|
||||
|
||||
## Recall design, and why there are no vectors yet
|
||||
|
||||
This slice ports HologramAgent's **symbol path**, not its vector path.
|
||||
|
||||
`docs/zh/recall-capability-tiers.md` measures four query shapes. The one that
|
||||
works *best* in production is 形态 1 (queries carrying an exact token: port, IP,
|
||||
path, version) — which is pure symbol matching. The one that needs vectors is
|
||||
形态 3 (pure generic description), and it is the weakest even *with* vectors
|
||||
(target block ranked 389/2686). So dropping embeddings here costs the tier that
|
||||
was already marginal, not the tier that carries the product.
|
||||
|
||||
Scoring follows HA exactly where it matters:
|
||||
|
||||
- An **exact numeric hit** is a *boolean pin* (`1.0 + lexical*0.01`), never a
|
||||
weight. Folding it into a weighted sum makes it lose to noise — the regression
|
||||
documented at `internal/memory/fusion.go:43-49`.
|
||||
- A matched **word** is a coverage signal, not an exact hit.
|
||||
- Single-character symbols are rejected (`abstain.go:115` "单字不成符号");
|
||||
without that gate a query like `billing0` yields a bare `0` that "exactly"
|
||||
matches every block containing a zero.
|
||||
- A matched word prefers blocks where it is **not a prefix of a longer word**
|
||||
(`billing` should beat `billing2`), loosely mirroring
|
||||
`isCrossWordTailMatch` (`distill/split.go:383`).
|
||||
|
||||
Two documented gaps, asserted rather than hidden (see checkpoint V2):
|
||||
|
||||
| gap | why | when |
|
||||
|---|---|---|
|
||||
| 纯泛化描述 queries ("公网地址是什么") | needs vectors | phase B |
|
||||
| 「库里没有」detection (abstention) | needs either vectors or a real CJK tokenizer | phase B/C |
|
||||
| CJK segmentation | `unicode61` keeps a CJK run as ONE token; compensated by a 2..4-char sliding-window fallback ported from `cjkWindows` (`fusion.go:240`) | phase C |
|
||||
|
||||
---
|
||||
|
||||
## Three harness constraints this slice discovered
|
||||
|
||||
Each was found by a failing test, not by reading types — they are recorded here
|
||||
because they will bite anyone writing a second dsh service plugin.
|
||||
|
||||
1. **A cordis service must not use JS `#private` fields.**
|
||||
`ctx.stablemem` is a **proxy**, not the instance you constructed, and private
|
||||
fields are bound to the declaring object:
|
||||
`Cannot read private member #store from an object whose class did not declare it`.
|
||||
Keep service state in ordinary properties.
|
||||
|
||||
2. **`isolate()` does not partition prompt-context names or tool names.**
|
||||
It partitions *service* lookups. Two instances in one realm still collide on
|
||||
`systemPrompt.context({name})` (`dsh-system-prompt/lib/index.js:204`) and
|
||||
`tools.register` unless the names differ. Hence the configurable `toolName`,
|
||||
and the prompt-context name derived from the database path
|
||||
(`memory:index:<key>`), which stays unique and stable.
|
||||
|
||||
3. **`ctx.systemPrompt.getContextOrder()` is an allocator, not a free constant.**
|
||||
Prefer a named slot (`WEB_SURFACE`) over a magic number; the numeric constants
|
||||
are private to the package.
|
||||
|
||||
---
|
||||
|
||||
## What this slice does not do
|
||||
|
||||
| not done | why | where it lands |
|
||||
|---|---|---|
|
||||
| embeddings / vectors | no embedding service in dsh (`docs/feasibility.md` §5.1) | phase B |
|
||||
| evicting L1 context into memory | dsh's compaction is summary-replacement, and `CompactionTrigger` carries no query (§5.3) | replaced by shadow-context + preset |
|
||||
| media blocks | dsh attachment is CAS; HA requires bytes inside the block (§5.4) | not planned |
|
||||
| cross-session / cross-agent memory | one sqlite file per `config.path` | phase D |
|
||||
| abstention | needs vectors or a real tokenizer | phase B/C |
|
||||
|
||||
---
|
||||
|
||||
## Verify
|
||||
|
||||
### Prerequisite: link the harness packages first
|
||||
|
||||
Nothing here is fetched as this plugin's own dependency. The `@deepseek-ai/*`
|
||||
packages that `lib/` imports at runtime are **peers**: they belong to the dsh
|
||||
install already on the machine, and they are not vendored here on purpose
|
||||
(a copy would drift from the harness this plugin is supposed to run inside).
|
||||
`npm install` does not resolve them either — `cordis` is the only one of the four
|
||||
that is non-optional, so npm fetches that one (plus its own transitive deps) and
|
||||
leaves the other three missing.
|
||||
|
||||
A fresh clone therefore needs one setup step before the suite can run:
|
||||
|
||||
```bash
|
||||
bash scripts/link-dsh.sh # symlinks the installed dsh packages into node_modules
|
||||
```
|
||||
|
||||
**Skip it and `npm test` and `npm run check` both fail with four files
|
||||
reporting `ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/…'`** —
|
||||
`loader`, `plugin`, `phase-b-g` and `phase-b-h` (the suites that mount a real
|
||||
dsh app). Those four die at import, so the run reports a handful of tests rather
|
||||
than the full suite. That is the expected symptom of a missing setup step, not a
|
||||
broken tree; run the script and both commands go green.
|
||||
|
||||
The script takes an optional argument for a non-default install root:
|
||||
|
||||
```bash
|
||||
bash scripts/link-dsh.sh /path/to/global/node_modules
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
npm test # the full suite
|
||||
npm run check # human-readable evidence, one line per checkpoint
|
||||
```
|
||||
|
||||
> **No test or checkpoint counts are stated here on purpose.** A number in a
|
||||
> README is an unguarded claim: it drifts every time someone adds a test, and a
|
||||
> stale count reads as precise while being wrong. Guarding it cost more than the
|
||||
> number was worth (a per-run source scan of the suite). The tables below name
|
||||
> *what* is verified; `npm test` / `npm run check` are the authority on *how many*.
|
||||
|
||||
`npm run check` prints one line of evidence per checkpoint. The mapping to
|
||||
`docs/feasibility.md` §7.1:
|
||||
|
||||
> Writing or changing a criterion? Read
|
||||
> [`docs/dev-notes-mutation-criteria.md`](docs/dev-notes-mutation-criteria.md) first. It
|
||||
> records the three ways a mutation test silently stops working — self-tests that
|
||||
> never touch `lib/`, hardcoded enumeration lists, and mutating shared state under
|
||||
> a parallel test runner — all of which were hit and independently reproduced
|
||||
> during this phase.
|
||||
|
||||
| checkpoint | proves |
|
||||
|---|---|
|
||||
| **V1** | `node:sqlite` opens, WAL applies, 100 writes round-trip |
|
||||
| **V2** | recall quality per capability tier: tiers 1–2 must pass, tiers 3–4 pinned as gaps |
|
||||
| **V3** | `systemPrompt.context` exposes `agent` and **cannot** see this step's messages |
|
||||
| **V3b** | `agent/pre-step` injection lands in the accepted batch with our `source.kind` |
|
||||
| **V3b⚠** | *known blind spot:* V3b's batch is 2 messages, so it cannot detect the real-batch bug. The regression tests in `test/plugin.test.js` cover it — see above |
|
||||
| **V3b†** | mutation — silencing recall makes V3b fail (the test can turn red) |
|
||||
| **V4** | `tools.register` schema + `render` contract, executed end to end |
|
||||
| **V5** | fiber disposal closes the handle; the file is reopenable |
|
||||
| **A5†** | mutation — containment-only recall loses discrimination (why FTS is primary) |
|
||||
| **MIG3** | a phase-A database opens, migrates both tables, and stays fully writable (blocks *and* centroid) |
|
||||
|
||||
Plus three regression tests for the real-batch bug described above
|
||||
(`test/plugin.test.js`): extraction, end-to-end 3-part batch, and a mutation that
|
||||
reintroduces the old rule and asserts the hit count falls to zero.
|
||||
|
||||
### Expectations from a real run
|
||||
|
||||
Pasted from an actual `npm run check` (not hand-maintained — this section drifted
|
||||
twice by being transcribed by hand):
|
||||
|
||||
```
|
||||
dsh-memory · phase A checkpoints
|
||||
|
||||
✔ V1 node:sqlite opens, WAL applies, 100 concurrent writes round-trip
|
||||
journal=wal, rows=100, mid-batch row=blk_cad13afe6824, file on disk
|
||||
✔ V2 recall quality per capability tier (tiers 1–2 must pass; 3–4 are pinned gaps)
|
||||
tier1(exact) 30/30 = 100% · tier2(domain) 20/20 = 100% · known gaps: 泛化查询→0 hit, 库里没有→0 hit (需向量,phase B)
|
||||
✔ V3 systemPrompt.context: sees agent, cannot see this step’s messages
|
||||
AssembleContext keys = [agent] — no messages/input field
|
||||
✔ V3b agent/pre-step: injected recall lands in the accepted message batch
|
||||
batch 2 msgs; injected kind=dsh-memory, carries "4324"
|
||||
✔ V3b† MUTATION: silencing recall must break V3b (the test can turn red)
|
||||
with recall disabled the batch stays at 1 message ⇒ the V3b assertion would fail
|
||||
✔ V3c realistic 3-part batch: recall uses the question, not the injected context
|
||||
batch 4 msgs, hit "4324" · old-rule hits=0 vs fixed hits=1
|
||||
✔ V4 ctx.tools.register: stablemem_commit validates args and renders output
|
||||
1 tool(s) visible; execute → "Committed block blk_0be019e8b0e4 (stored without a vector)."
|
||||
✔ V5 unload disposes the sqlite handle; a second close is a no-op
|
||||
fiber dispose → handle closed; reopened same file successfully
|
||||
✔ A5† MUTATION: swapping FTS5 for containment breaks discrimination
|
||||
FTS+pin → 1 hit for "4324"; containment → 2 hits (discrimination lost)
|
||||
✔ MIG3 phase-A database opens, migrates, and stays fully writable
|
||||
uv=1, blocks=10 cols, centroid=7 cols, legacy block readable + FTS-matching
|
||||
|
||||
<all checkpoints passed>
|
||||
|
||||
plugin: name=dsh-memory inject=[systemPrompt, tools]
|
||||
workspace: /tmp/dsh-memory-cp-9IOx5k (removed)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ported from HologramAgent
|
||||
|
||||
Source is read-only reference (`/home/program/TrueAgent`); nothing is vendored.
|
||||
|
||||
| this repo | HologramAgent |
|
||||
|---|---|
|
||||
| `lib/store.js` `BlockStore` | `memory.GraphDB.PutMemoryBlocks` (`internal/memory/block.go:203`) |
|
||||
| `lib/store.js` `blockId` | `distill.BlockID` (`internal/memory/distill/blocks.go:99`) |
|
||||
| `lib/recall.js` `querySymbols` | `memory.QuerySymbols` (`internal/memory/fusion.go:145`) |
|
||||
| `lib/recall.js` scoring / pin | `memory.SymbolScore` (`fusion.go:284`), pin at `:427` |
|
||||
| `lib/recall.js` `isNumericSymbol` | `fusion.go:316` |
|
||||
| `lib/recall.js` `cjkNeedles` | `cjkWindows` (`fusion.go:240`) |
|
||||
| `lib/recall.js` single-char gate | `AbstainCheck` guard (`abstain.go:115`) |
|
||||
| `lib/index.js` `renderIndex` header | `crossModalMarkdown` (`internal/agent/core/crossmodal.go:256`) |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
29
cordis.patch.yml
Normal file
29
cordis.patch.yml
Normal file
@ -0,0 +1,29 @@
|
||||
# Ship-ready overlay that swaps the harness loop for `dsh-memory/loop`.
|
||||
#
|
||||
# This is the ONLY artefact that proves the replacement loop is mounted: without
|
||||
# it `lib/loop.js` is a subclass nobody instantiates (its unit tests exercise the
|
||||
# pure `promoteSnapshotSections` function and nothing else).
|
||||
#
|
||||
# Apply it to a profile (a `--patch` overlay, or copied into the profile's own
|
||||
# `cordis.patch.yml`); `dsh` composes it over `@deepseek-ai/dsh-base`, where the
|
||||
# `agent-loop` row already exists:
|
||||
#
|
||||
# dsh --profile headless --patch <this file> "<task>"
|
||||
#
|
||||
# ★ The disable is not optional, and it is by ID, not by name. `AgentLoop`'s
|
||||
# constructor ends with `ctx.effect(() => ctx.agents.setFactory(this))`
|
||||
# (dsh-agent-loop/lib/index.js:1590), and `dsh-agent`'s `setFactory` throws when a
|
||||
# factory is already registered (dsh-agent/lib/index.js:429-437). Leaving the base
|
||||
# row enabled makes startup fail with "an agent factory is already registered";
|
||||
# `HologramAgentLoop` inherits that constructor, so it registers exactly once —
|
||||
# but only if the base row is gone.
|
||||
- id: agent-loop
|
||||
disabled: true
|
||||
|
||||
# The replacement. No `config` block on purpose: `HologramAgentLoop` defines no
|
||||
# `static Config`, so it inherits the parent schema (`maxParallelToolCalls`,
|
||||
# `agents`) and the profile's own `agent-loop` config rows keep working through
|
||||
# the inherited constructor.
|
||||
- insert:
|
||||
- id: hologram-agent-loop
|
||||
name: dsh-memory/loop
|
||||
363
docs/dev-notes-mutation-criteria.md
Normal file
363
docs/dev-notes-mutation-criteria.md
Normal file
@ -0,0 +1,363 @@
|
||||
# 变异判据的四类失效模式 + 两条纪律
|
||||
|
||||
> 来源:phase B(H 组 W1–W12)三轮返工中实际踩到、并由评审独立复现的坑。
|
||||
> 四条都不是本任务特有的,**任何写变异判据的人都会再踩**。第五、六类是纪律,不是失效模式。
|
||||
>
|
||||
> 共同点:**它们都让判据绿着,而判据守的东西已经坏了。** 这类问题比被测代码坏了更难发现,
|
||||
> 因为失败的信号本身就是「一切正常」。
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话总纲
|
||||
|
||||
> **变异体必须让 `lib/` 下的代码变坏。**
|
||||
> 一旦变异动的是 `test/`、或者断言的是测试里自己拼的局部函数,它就不是变异,是自测。
|
||||
|
||||
把这一条当硬规则执行,下面四条里前两条会被自动拦住。
|
||||
|
||||
---
|
||||
|
||||
## 1. 自测型假变异(犯得最多:四次)
|
||||
|
||||
### 形态
|
||||
|
||||
"变异"测试在 `test/` 里自己拼一个替身,再断言替身的行为:
|
||||
|
||||
```js
|
||||
// ✗ 这不是变异
|
||||
test('W11d†a: restoring the short-circuit breaks it', () => {
|
||||
const withShortCircuit = (hits) => (hits.length === 0 ? '' : renderRecall(hits)) // 自己拼的
|
||||
assert.equal(withShortCircuit(empty), '', 'the short-circuit does silence it')
|
||||
})
|
||||
```
|
||||
|
||||
这段**一行 `lib/` 代码都没碰**。它永远不会随实现回滚而变红,却按「已覆盖一条变异」计数。
|
||||
|
||||
### 实测证据
|
||||
|
||||
| 变异(真改 `lib/`) | 期望 | 实测 |
|
||||
|---|---|---|
|
||||
| M-a 只回退 `renderRecall` 短路 | `W11d†a` 红 | **`W11d†a` 绿**,只有 `W11d` 红 |
|
||||
| M-b 只加回 `contextForStep` 短路 | `W11d†b` 红 | **`W11d†b` 绿**,只有 `W11d` 红 |
|
||||
|
||||
### 正确形态
|
||||
|
||||
真改源码 → 重新 import → 断言行为翻转 → 还原。
|
||||
|
||||
```js
|
||||
await withMutatedCopy('recall.js', [
|
||||
[' if (lines.length === 0 && notice === \'\') return \'\'',
|
||||
' if (lines.length === 0) return \'\' // MUTATED'],
|
||||
], async (mod) => {
|
||||
const hits = []; hits.degraded = 'embed_error'
|
||||
assert.equal(mod.renderRecall(hits, 'x'), '', 'mutation makes it render nothing')
|
||||
})
|
||||
```
|
||||
|
||||
三条要求:
|
||||
|
||||
1. **改的是 `lib/`**,不是 `test/`。
|
||||
2. **锚点找不到时显式失败**(`assert.ok(text.includes(from))`),否则源码一改名,变异静默失效。
|
||||
3. **改在副本上**,见 §3。
|
||||
|
||||
### 自查问句
|
||||
|
||||
> 把 `lib/` 完全回滚到上一个版本,这条测试会红吗?
|
||||
> 不会 → 它是自测,删掉,别留在计数里。
|
||||
|
||||
---
|
||||
|
||||
## 2. 清单写死(判据的输入与代码脱节)
|
||||
|
||||
### 形态
|
||||
|
||||
判据遍历一份**手写**的清单,而清单本该覆盖的东西由代码决定:
|
||||
|
||||
```js
|
||||
const MODES = ['no_embedder', 'embed_error', 'aborted', 'vector_ready_unused',
|
||||
'future_mode_a', 'future_mode_b'] // ✗ 手写
|
||||
```
|
||||
|
||||
`future_mode_a/b` 这类名字有误导性:它们测的是「**掉进 `default:` 的**未知模式」,
|
||||
永远测不到「**有人新写了一个 `case`**」。而正常演进偏偏就是新写 `case` ——
|
||||
那种时候 `default:` 和手写清单**两头都管不到**。
|
||||
|
||||
### 实测证据
|
||||
|
||||
在 `lib/recall.js` 里植入一个**会撒谎**的新分支:
|
||||
|
||||
```js
|
||||
case 'brand_new_mode':
|
||||
return '[Memory] New mode degraded; matches below may be incomplete.' // 零命中时是谎话
|
||||
```
|
||||
|
||||
| 版本 | `W11f` | `W11f†` | `W11f†b` |
|
||||
|---|---|---|---|
|
||||
| 写死 `MODES` | **绿** | 绿 | 绿 |
|
||||
| 源码扫描 | **红** | 绿 | 绿 |
|
||||
|
||||
写死版全绿,因为 `brand_new_mode` 不在数组里。
|
||||
|
||||
### 正确形态
|
||||
|
||||
**清单从被测代码推导**,而不是手抄:
|
||||
|
||||
```js
|
||||
const src = readFileSync(new URL('../lib/recall.js', import.meta.url), 'utf8')
|
||||
const body = src.slice(src.indexOf('export function degradationNotice('))
|
||||
const declared = [...body.slice(0, body.indexOf('\n}\n')).matchAll(/case '([^']+)':/g)]
|
||||
.map((m) => m[1])
|
||||
```
|
||||
|
||||
### ★ 但「自动推导」不能替代「提取本身也要被守卫」
|
||||
|
||||
推导源必须是判据真正要管的那件事。`case 'literal'` 恰好就是「有哪些模式」的唯一事实源;
|
||||
一旦 `degradationNotice` 改成表驱动(模式放进映射表),扫描就会**静默扫到空数组而照样绿**。
|
||||
|
||||
所以必须配两道:
|
||||
|
||||
| 守卫 | 作用 | 实测 |
|
||||
|---|---|---|
|
||||
| `declared.length >= 4` + 必需 mode 存在性 | 提取失效时**响亮失败** | 把 `case 'x'` 全改成常量引用(语法合法、仍可导入)→ `expected at least 4 case literals, found 0` |
|
||||
| `W11f-a`(guards the guard) | 用变异塞一个新 `case`,断言扫描**看得见**它 | 塞 `mode_added_by_the_future_test` → 能取到 |
|
||||
|
||||
`W11f-a` 不是冗余:**「扫描器坏掉」是这套判据唯一能静默失效的方式。**
|
||||
|
||||
### 同一族的另一种:README 里的数字
|
||||
|
||||
README 曾写着「N tests / M checkpoints」,被手改过五次,两次是在 commit message 里漂的。
|
||||
加守卫来盯着这个数字,成本高于数字本身的价值。
|
||||
|
||||
**结论:删掉,不守着。** 留下的守卫反过来断言「README 不得出现计数」:
|
||||
|
||||
```js
|
||||
[/\b\d+\s+tests\b/i, 'a test count'],
|
||||
[/\b\d+\s+checkpoints\b/i, 'a checkpoint count'],
|
||||
[/\b\d+\s*\/\s*\d+\s+checkpoints passed/i, 'a pasted pass-count'],
|
||||
```
|
||||
|
||||
三种写法实测都能抓住。**一个没有人验证的数字,给人一种精确的假象。**
|
||||
|
||||
> 注意区分两类数字:
|
||||
> - **手抄的运行计数**(「119 tests」)——会漂,删。
|
||||
> - **制品自身的内容量**(「fixture 里有 45 个 case」)——由文件决定,但同样没有守卫,
|
||||
> 所以也不要写进文档,改为指向文件本身。
|
||||
|
||||
---
|
||||
|
||||
## 3. 变异共享状态与并发撞车(ESM / 测试并行)
|
||||
|
||||
### 形态
|
||||
|
||||
`node --test` **默认并行跑测试文件**(每个文件一个进程)。变异若直接改写共享的 `lib/*.js`:
|
||||
|
||||
```js
|
||||
// ✗ 与其它测试文件并发时不安全
|
||||
writeFileSync(src, mutated) // 别的文件此刻可能正在 import 它
|
||||
const mod = await import(`${url}?mut=1`)
|
||||
writeFileSync(src, original)
|
||||
```
|
||||
|
||||
别的测试文件在变异窗口内 import,就读到中间态。
|
||||
|
||||
### 实测证据
|
||||
|
||||
- 症状:**约 1/30** 偶发失败,看起来像「断言 flaky」而不是 harness 竞态。
|
||||
- 具体表现:`W11d†` 抓到的是**未变异**的模块,于是断言失败但原因完全错位。
|
||||
- 更隐蔽的一层:`A1b`(逐字节比对 `recallBlocks` 源码)、`G2b`/`G5`(扫 `lib/` 全目录)
|
||||
这三条**专门读源码的判据**,在就地变异下会读到被改坏的中间态。
|
||||
|
||||
### 正确形态
|
||||
|
||||
**在副本上变异**:
|
||||
|
||||
```js
|
||||
const dir = mkdtempSync(join(tmpdir(), 'mut-'))
|
||||
cpSync(libDir, join(dir, 'lib'), { recursive: true })
|
||||
cpSync(join(pkgRoot, 'package.json'), join(dir, 'package.json'))
|
||||
cpSync(join(pkgRoot, 'node_modules'), join(dir, 'node_modules'), { recursive: true, dereference: false })
|
||||
// 改 dir/lib/<file>,import dir/lib/<file>
|
||||
// finally: rmSync(dir, ...)
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
1. 复制 **`lib/` 整体**(保留目录形状,相对 import 才能在副本内解析到被变异的兄弟模块)。
|
||||
2. 复制 `package.json`(`"type": "module"`)。
|
||||
3. `node_modules` **软链/保留符号链接**(`dereference: false`),否则 peer 依赖解析不到。
|
||||
4. `finally` 里 `rmSync`,否则 `/tmp` 堆副本。
|
||||
5. 锚点匹配不到时**显式失败**,别静默跳过。
|
||||
|
||||
### 验证方式
|
||||
|
||||
- 连续 30 次全量跑,0 失败(改前约 1/30 偶发)。
|
||||
- 跑完 `ls -d /tmp/mut-?????? | wc -l` 应为 0(无残留)。
|
||||
- 并行压力:`a + g-static + h` 三文件同跑 5 轮 0 红。
|
||||
|
||||
> 附带的 ESM 坑:`package.json` 里 `"type": "module"` 时,测试文件不能用 `require()`,
|
||||
> 一律 `await import('node:fs')`。用 `require` 会报
|
||||
> `Cannot determine intended module format`(顶层 await 与 require 并存)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 判据被替换时,旧判据的覆盖静默消失
|
||||
|
||||
### 形态
|
||||
|
||||
一条测试同时承担两件事。你删掉其中一件时,另一件的覆盖跟着没了——
|
||||
**而没有任何东西会因为少了这个覆盖而变红。**
|
||||
|
||||
本阶段真实发生:旧的 README 守卫同时含两条断言:
|
||||
|
||||
```js
|
||||
// ① README 的计数与实现一致(后判定不值得守)
|
||||
assert.equal(Number(claim[1]), declared, ...)
|
||||
// ② checkpoints.js 至少注册了一条 ← 值得守
|
||||
assert.ok(declared > 0, 'no checkpoint calls found — did the helper get renamed?')
|
||||
```
|
||||
|
||||
删①时把整条 test 换掉了,②一起消失。
|
||||
|
||||
### 实测后果(很安静)
|
||||
|
||||
把 helper 换成一个什么都不做的空操作(调用点全保留):
|
||||
|
||||
```
|
||||
$ npm run check
|
||||
0/0 checkpoints passed
|
||||
$ echo $?
|
||||
0 ← 退出码 0
|
||||
$ npm test
|
||||
# tests 119 # pass 119 # fail 0 ← 全套仍全绿
|
||||
```
|
||||
|
||||
**checkpoint 全废了,`npm run check` 报「通过」,119 条测试一条都不红。**
|
||||
|
||||
### 正确形态:守卫要守结果,不要守成因
|
||||
|
||||
补回一条静态计数只能抓「helper 改名」。放在 runner 里守**结果**,能覆盖所有成因:
|
||||
|
||||
```js
|
||||
if (results.length === 0) {
|
||||
console.error('no checkpoints were registered — did the helper get renamed or stubbed?\n')
|
||||
process.exitCode = 1
|
||||
}
|
||||
```
|
||||
|
||||
| 成因 | runner 内守卫 | 静态计数 |
|
||||
|---|---|---|
|
||||
| helper 改名 | ✔ | ✔ |
|
||||
| helper 被 stub 成空操作 | ✔ | ✘(调用点还在,计数不变) |
|
||||
| 条件跳过(`if (skip) return`) | ✔ | ✘ |
|
||||
| 提前 `process.exit(0)` | ✘(**拦不住**) | ✘ |
|
||||
|
||||
最后一行是关键:**`process.exit(0)` 绕过任何脚本内的检查**。所以还要一条外部断言,
|
||||
证明 runner 里仍然含有那道守卫:
|
||||
|
||||
```js
|
||||
assert.match(script, /if \(results\.length === 0\)/)
|
||||
assert.match(script, /results\.length === 0[\s\S]{0,200}process\.exitCode = 1/)
|
||||
```
|
||||
|
||||
### 自查问句
|
||||
|
||||
> **这条判据除了它名字里说的那件事,还顺手守了什么?**
|
||||
>
|
||||
> 删/改之前,把它的断言逐条过一遍,对每一条问:
|
||||
> 「这条如果不守了,谁会红?」——答案是「没人红」的那些,要么补新守卫,要么明确记下放弃。
|
||||
|
||||
> 附一条探针经验:验证「零注册」不要用「把 `await checkpoint(` 注释掉」——
|
||||
> 那会留下孤立的 `return`,报 `SyntaxError: Illegal return statement`,测的是语法。
|
||||
> 用「换成一个仍存在但什么都不做的函数」,调用点保持不变,才是有效探针。
|
||||
|
||||
---
|
||||
|
||||
## 5. 判据降级时,覆盖面必须一起交代
|
||||
|
||||
不是失效模式,是一条**记账纪律**:判据被削弱时,削弱本身要写进判据注释,
|
||||
否则后来人会把它当成完整证据。
|
||||
|
||||
本阶段实例:`A1b` 原本逐字节锁死 `recallBlocks` 源码,F2 合法地要改这个函数,
|
||||
于是降级为「不传 fingerprint 时行为不变」。但降级后的 `A1b-behaviour`
|
||||
只跑 golden 的**前 5 个** case —— 变异 A(非精确带分数微调)下它可能仍是绿的。
|
||||
|
||||
```
|
||||
所以注释里必须写明:
|
||||
结论不由 A1b 单独给出,而由 A2 兜(A2 走全部 45 例、比精确分数)。
|
||||
A1b-behaviour 只是 no-fingerprint 路径的廉价烟测,不是权威。
|
||||
```
|
||||
|
||||
自查问句:
|
||||
|
||||
> **这条判据降级后,原本由它兜住的结论现在由谁兜?**
|
||||
> 答案必须是一个具体的、覆盖更广的判据名;答不出来就说明降级降过头了。
|
||||
|
||||
**反例(同一纪律缺失的样子)**:只把测试改窄、不写「为什么不影响结论」,
|
||||
后来人看到绿就以为全绿 —— 与「自测型假变异」的最终效果一样,只是路径不同。
|
||||
|
||||
---
|
||||
|
||||
## 6. 恒为 0 / 恒为真 的字段(判据咬不住的占位符)
|
||||
|
||||
**形态**:字段存在、被赋值、有测试,但那个值永远不变。
|
||||
|
||||
本阶段两次实例,都是同一类:
|
||||
|
||||
```js
|
||||
// ① 手抄常量
|
||||
assert.equal(hits.skipped, 2) // fixture 一改就悄悄不再描述事实
|
||||
// ② 占位符冒充度量
|
||||
last_recall.skipped_dim = 0 // 注释:"reported as 0 rather than guessed"
|
||||
assert.equal(typeof last.skipped_dim, 'number') // 常量永远满足
|
||||
```
|
||||
|
||||
②比①更隐蔽:①至少写了个具体数字,②连数字都没有,只有一个类型断言,
|
||||
看起来像在测、实际什么都没测。而它顶着的是一条真实需求
|
||||
(F4「维度失配与指纹失配分开计数」)—— 于是那条需求在文档上成立、
|
||||
在代码里是摆设。
|
||||
|
||||
**修法二选一,不许两头留**:
|
||||
|
||||
| 选项 | 做法 |
|
||||
|---|---|
|
||||
| 让它能非零 | 补上产生该值的路径(本例:召回路径加维度过滤),并加判据断言非零 |
|
||||
| 认账删掉 | 从对外形状里删掉该字段,并同步改文档,写明它不在这一层管 |
|
||||
|
||||
**自查问句**:
|
||||
|
||||
> **这个字段的取值集合里,有没有一个能让我这条断言失败?**
|
||||
> 没有 → 它不是度量,是占位符。
|
||||
|
||||
**同族的第三种**:`typeof x === 'number'` / `x !== undefined` / `x.length >= 0`
|
||||
这类断言对任何实现都成立,等于没写。它们和「恒 0 字段」是一回事:
|
||||
断言存在,但取值空间被断言本身排除了失败的可能。
|
||||
|
||||
---
|
||||
|
||||
## 通用自查清单
|
||||
|
||||
写/改一条变异判据前,逐条过:
|
||||
|
||||
- [ ] 变异体**只改 `lib/`**(改 `test/` 的是自测)
|
||||
- [ ] 把 `lib/` 回滚到上一版,这条测试**会红**
|
||||
- [ ] 锚点找不到时**显式失败**,不是静默 pass
|
||||
- [ ] 清单/枚举从**代码推导**,不手抄
|
||||
- [ ] 推导本身有**长度下限 + 必需项**断言;另有一条 guards-the-guard
|
||||
- [ ] 变异在**副本**上做,`finally` 清理,不碰共享树
|
||||
- [ ] 跑完确认**无残留**(`.orig` / `.rej` / `/tmp/mut-*`)
|
||||
- [ ] 连跑 **≥5 次**(间歇性问题 3 次绿证明不了什么)
|
||||
- [ ] 文档里**没有**手抄的运行计数
|
||||
- [ ] 替换/删除旧判据时,逐条问过「这条覆盖现在谁守」
|
||||
- [ ] 判据被降级/收窄时,注释里写明「结论现在由谁兜」
|
||||
- [ ] 没有恒为 0 / 恒为真的字段;每个被断言的度量都至少能取一个让断言失败的值
|
||||
|
||||
## 一句话记住六条
|
||||
|
||||
| 坑 | 一句话 |
|
||||
|---|---|
|
||||
| 自测型假变异 | 变异不碰 `lib/`,它就永远不会红。 |
|
||||
| 清单写死 | 判据的输入和代码脱节时,绿的是记忆不是事实。 |
|
||||
| 并发撞车 | 变异共享状态不能与代码并发跑 —— 改副本。 |
|
||||
| 替换带走覆盖 | 一条判据往往守两件事;删一件时另一件不会有人提醒你。 |
|
||||
| 降级不记账 | 判据收窄后,原本由它兜的结论必须有新名字接手,且写进注释。 |
|
||||
| 恒值字段 | 一个永远不变的值配一个 `typeof` 断言,等于没测。 |
|
||||
437
docs/phase-b-criteria.md
Normal file
437
docs/phase-b-criteria.md
Normal file
@ -0,0 +1,437 @@
|
||||
# Phase B 判据与变异规则草案
|
||||
|
||||
> 状态:**草案,待批**(decision 2 未回)。不含实现。
|
||||
> 前置已定:
|
||||
> - **decision 1**:向量走 HA 的 `http` provider 转发,不背 ONNX;`graph_centroid` 按 http 的维度语义写入。
|
||||
> - **decision 3**:保留「跳过不报错」,但失配计数必须暴露到工具面(可查询),不许只留日志。
|
||||
> 范围:只覆盖**向量落地**(`docs/feasibility.md` §7.1 阶段 B)。L2 冷文档、影子上下文不在本文。
|
||||
>
|
||||
> 取证:HologramAgent 源码只读(`/home/program/TrueAgent`)+ 本插件 phase A 现状。
|
||||
|
||||
---
|
||||
|
||||
## 0. 开工前必须先定的 4 件事(含我的建议)
|
||||
|
||||
这 4 条不定死就写实现,后面每个都要动召回主路径。按我判断的严重度排序。
|
||||
|
||||
### 0.1 ★ fingerprint 的派生式漏了 endpoint(这是我原方案的隐患)
|
||||
|
||||
HA 的 http provider 这样派生指纹(`internal/memory/vector/http_embedder.go:99`):
|
||||
|
||||
```go
|
||||
if cfg.Fingerprint == "" {
|
||||
cfg.Fingerprint = "http:" + cfg.Model + fmt.Sprintf(":%d", cfg.Dimension)
|
||||
}
|
||||
```
|
||||
|
||||
**指纹只含 model + dimension,不含 endpoint。** 后果:
|
||||
|
||||
| 场景 | 指纹 | 实际向量空间 | 结果 |
|
||||
|---|---|---|---|
|
||||
| `endpoint=A, model=jina-v5, dim=2048` | `http:jina-v5:2048` | A 的权重 | — |
|
||||
| 改成 `endpoint=B, model=jina-v5, dim=2048` | `http:jina-v5:2048` | **B 的权重** | **指纹相同 ⇒ 静默混用两个空间** |
|
||||
|
||||
「同名模型、不同部署」是常见情况(换机器、A/B 两个向量服务、云端换 region)。HA 这条在单机单服务下没暴露,插件形态下我们更容易遇到(用户自己填 endpoint)。
|
||||
|
||||
**建议**:指纹 = `http:<model>:<dim>:<endpoint 的 8 位 hash>`,并允许 `config.fingerprint` 显式覆盖(HA 已有这个口子)。
|
||||
**代价**:换 endpoint 会触发一次全量重嵌。但那本来就是对的——不同 endpoint 可能就是不同权重。
|
||||
|
||||
> 需要你拍板:**采纳扩展派生式,还是保持与 HA 逐字同构**?
|
||||
> 我倾向扩展,理由如上;若你要求「与 HA 同构优先于正确性」,我就退回原式并在计数器里额外暴露 `endpoint`,让运维能自己看出两个服务同名。
|
||||
|
||||
### 0.2 ★ 空 fingerprint 的块现在会被静默放行
|
||||
|
||||
HA 的跳过条件是三个 `&&`(`internal/memory/block_recall.go:140-143`):
|
||||
|
||||
```go
|
||||
if q.Fingerprint != "" && b.Fingerprint != "" && b.Fingerprint != q.Fingerprint {
|
||||
skippedFP++
|
||||
continue
|
||||
}
|
||||
```
|
||||
|
||||
⇒ **块的 fingerprint 为空时不跳过**。也就是说「历史遗留、没有指纹的带向量块」永远被视为可比。
|
||||
|
||||
而 `BlockVectorStats` 单独统计了这种块(`block_recall.go` 的 `MissingFP`),说明作者知道它存在——但在召回路径上没拦住。
|
||||
|
||||
对本插件的影响更具体:**phase A 写进去的块全都没有向量**。phase B 回填时,"无向量"和"有向量但指纹空"是两种完全不同的状态,混在一起就没法告诉用户该怎么修。
|
||||
|
||||
**建议**:三态而非两态,且**默认 fail-closed**(空指纹的带向量块按不可比处理,进 `no_fingerprint` 计数而不是静默放行)。
|
||||
|
||||
| 状态 | 条件 | 补救 |
|
||||
|---|---|---|
|
||||
| `no_vector` | vector 为空 | 回填(调 embedding 补算) |
|
||||
| `no_fingerprint` | 有 vector、fp 为空 | 重嵌(无法判断属于哪个空间) |
|
||||
| `stale` | 有 vector、fp 非空但 ≠ active | 重嵌到当前空间 |
|
||||
| `active` | 有 vector、fp == active | 可召回 |
|
||||
|
||||
**已定(裁定,不再待拍板):fail-closed,且写侧直接拒收。**
|
||||
|
||||
> 理由:无名向量的块(有 `vector` 但 `fingerprint` 为空)**直接拒收**,比
|
||||
> 「放过再靠下游兜」更能守住空间隔离。安全语义优先,**不为 HA 的疑似缺陷同构** ——
|
||||
> HA 在 `block_recall.go:140` 的三个 `&&` 让空指纹块被放行,这是我们要显式修正的
|
||||
> 行为,不是要复刻的行为。
|
||||
|
||||
落点分两层,缺一层都不算落实:
|
||||
|
||||
| 层 | 行为 | 位置 |
|
||||
|---|---|---|
|
||||
| **写侧(已实现)** | `putBlock` 收到 vector 但无 fp ⇒ **抛错**,块不入库 | `lib/store.js` 的 `putBlock` |
|
||||
| **读侧(F 组)** | 万一库里有声明前的存量行(fp 空 + vector 非空)⇒ 交给下游兜,归 `unknownSpace` 计数,**不参与向量召回** | F2/F3 的过滤与计数 |
|
||||
|
||||
写侧拒收是主防线,读侧归类是存量兜底 —— 两者都要,因为「拒收新数据」管不了
|
||||
「已经躺在库里的行」。
|
||||
|
||||
### 0.3 `graph_centroid` 的 DDL 与 HA 不一致,现在改是免费的
|
||||
|
||||
phase A 我建的表(`lib/store.js` 的 `DDL`):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS graph_centroid (
|
||||
fingerprint TEXT PRIMARY KEY,
|
||||
dim INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL,
|
||||
sum_json TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
HA 的表(`internal/memory/centroid_store.go:58-66`):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS graph_centroid (
|
||||
fingerprint TEXT PRIMARY KEY,
|
||||
dim INTEGER NOT NULL,
|
||||
vector_count INTEGER NOT NULL,
|
||||
vector TEXT NOT NULL,
|
||||
stats TEXT NOT NULL,
|
||||
built_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
```
|
||||
|
||||
差别:我少一个 `stats` 列(HA 用它存 `centroidMeta`:`Anomalous`/`MeanLength`/`VectoredCount`/`BuiltAt`),列名也不同。`stats` 不是装饰——`LoadCentroid` 靠 `meta.Anomalous` 决定要不要启用校正(`centroid_store.go:205-209`),没有它就无法复现 HA 的判定。
|
||||
|
||||
**现在表是空的 ⇒ 改 DDL 零成本。一旦写进任何一行就要做迁移。**
|
||||
|
||||
**建议**:按 HA 的列对齐(`vector_count` / `vector` / `stats` / `built_at`),并在 `stats` 里存 `centroidMeta` 同构 JSON。phase A 那个 DDL 是我的疏忽——当时只求"表先建好省一次迁移",没对齐列。
|
||||
|
||||
### 0.4 向量化失败(网络断/超时)算不算失配?
|
||||
|
||||
decision 3 说的是**指纹失配**要可查询。但同一个"静默降级"问题在网络失败时也存在,且 HA 明写要静默(`internal/agent/core/toolcall.go:1271-1274`):
|
||||
|
||||
```go
|
||||
// 向量化失败不报错到工具层:符号路仍可用,而这条失败通常意味着
|
||||
// 模型未加载/超时,报给模型只会让它以为"记忆不存在"。
|
||||
return ""
|
||||
```
|
||||
|
||||
**建议**:同样的处理——不报错、退回符号路、**但进计数器**(`last_recall.mode = 'symbol_only'` + `embed_error` 原因)。理由与 decision 3 完全同构:静默可以,静默到无人知道不行。
|
||||
|
||||
> 需要你拍板:**embedding 调用失败是否一并纳入计数器口径**(我建议纳入)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 判据总表
|
||||
|
||||
分组:`A` 前置重构 / `F` 指纹空间 / `V` 融合 / `C` 中心化 / `D` 降级与可见性 / `H` http provider / `G` 守卫。
|
||||
|
||||
每条格式:**断言 → 依据 → 变异(必须能红)**。
|
||||
|
||||
### A. 前置重构(没有这组,后面都写不了)
|
||||
|
||||
当前 `recallBlocks` 是**同步**函数(`lib/recall.js:223`)。向量化是异步的,所以:
|
||||
|
||||
| # | 断言 | 依据 |
|
||||
|---|---|---|
|
||||
| A1 | `recallBlocks` 保持同步,另新增异步 `recallBlocksHybrid`;phase A 的 26 个测试与 8 个 checkpoint **全部仍然通过**,一条不改语义 | 代码可核:`lib/recall.js:229` 是同步导出的 `recallBlocks`(函数体内 `await` 0 处),`lib/recall.js:613` 是 `async` 的 `recallBlocksHybrid` —— 异步只出现在新 wrapper |
|
||||
| A2 | 未配置 embedder 时,`recallBlocks` 的返回与 phase A **逐字节相同**(同序、同分、同 `why`) | 无向量是合法配置,不是降级 |
|
||||
|
||||
**A1 变异**:把 `recallBlocks` 改回同步并在内部 await 一个 Promise ⇒ 测试红(语法/类型层面即失败)。
|
||||
**A2 变异**:无 embedder 时仍给候选加一个常量向量分 ⇒ 顺序或分数与本快照不一致 ⇒ 红。
|
||||
|
||||
> ★ A2 的判据实现方式:把 phase A 的召回结果**固化成 golden fixture**(输入 query + 块集合 → 期望的 `[{id, score, exact, why}]`),phase B 之后跑同一个 fixture 必须完全一致。这是防止「加了向量顺手改了符号路排序」的唯一可靠办法。
|
||||
|
||||
### F. 指纹与向量空间
|
||||
|
||||
| # | 断言 | 依据 |
|
||||
|---|---|---|
|
||||
| F1 | 块写入时 `vector` 与 `fingerprint` **同事务同存**,且 fingerprint 取自 provider 自报(`Info().Fingerprint`)而非配置抄写 | HA 的 `PutMemoryBlocks` 把两者写在同一 `INSERT`(`block.go:203-300`);pub SPI 的 `Info.Fingerprint` 契约是「provider build 变了就必须变」(`pkg/embedding/embedding.go`) |
|
||||
| F2 | 召回时 fp 失配的块**被跳过**(不抛错、不出现在结果里) | `block_recall.go:140-143` |
|
||||
| F3 | 失配块**计数可见**,且经工具可查询 | **decision 3** |
|
||||
| F4 | 维度失配与指纹失配**分开计数**,且**两个都能非零**:召回路径按 `fingerprint` 与 `dimension` 各过滤一次,分别产出 `skipped` / `skippedDim` | HA 分开:`skippedDim` 与 `skippedFP`(`block_recall.go:126` / `:136` / `:141`)。补救不同:维度失配 ⇒ 换 provider 配置;指纹失配 ⇒ 重嵌。<br>★ 恒为 0 的字段等于没做(参见下条) |
|
||||
| F5 | 空 fingerprint 的带向量块**不被静默接受**:写侧 `putBlock` 直接拒收(抛错);读侧若遇存量行则归 `unknownSpace`,不参与向量召回 | **§0.2 已定:fail-closed**。HA 此处 fail-open(`block_recall.go:140` 三个 `&&`),我们**刻意不同构** —— 安全语义优先 |
|
||||
| F6 | 指纹变更后旧块的三段行为,**一个用例内同时断言**(见下) | 对应 HA 的 `skippedFP` 语义 + 本插件「不删数据」原则 |
|
||||
| F7 | 混合空间共存时,召回只用 active fp;store 级统计按 fp **分组**列出(含块数、维度、状态) | HA 的 `BlockVectorStats.Fingerprints` / `Dimensions`(`block_recall.go` 的 `BlockVectorStats` 结构) |
|
||||
| F8 | 维度校验分两个入口,**都要**:<br>• **调用方显式传向量**(`putBlock` / `commit` 带 `vector`)且声明了 `expectedDimension` ⇒ **硬报错**,不落库<br>• **provider 返回的向量**宽度不对 ⇒ 降级为「不嵌入」(W4),不抛给用户 | `http_embedder.go:152` 与 `block_recall.go:135-138`("不截断、不填充") |
|
||||
|
||||
**变异**:
|
||||
- M-F2:删掉 fp 判断 ⇒ 失配块出现在结果里 ⇒ 红
|
||||
- M-F3:计数器恒返回 0 ⇒ 红
|
||||
- M-F4:把 dim 失配并入 fp 计数 ⇒ 分别断言的用例红
|
||||
- M-F4b:删掉召回路径的维度过滤 ⇒ `skippedDim` 恒 0、窄向量被召回 ⇒ 红
|
||||
|
||||
★ **恒 0 字段的教训**:`last_recall.skipped_dim` 第一版写死 `0`,注释是
|
||||
「reported as 0 rather than guessed」,而唯一断言是 `typeof === 'number'`
|
||||
—— 常量永远满足,等于把「F4 分开计数」做成摆设。这与更早那处
|
||||
`assert.equal(hits.skipped, 2)` 是同一类:**判据咬不住的东西就是没做**。
|
||||
裁定选 (a):召回路径补维度过滤,让该字段能非零;而不是把字段删掉。
|
||||
- M-F5:删掉 `putBlock` 的 fp 守卫 ⇒ 无名向量的块被静默接受 ⇒ 红(写侧)
|
||||
- M-F5b:读侧把空 fp 当匹配 ⇒ `unknownSpace` 计数为 0 且该块被召回 ⇒ 红(读侧)
|
||||
- M-F6:召回不过滤旧块 ⇒ 「召回层」那半红;或把旧块删掉 ⇒ 「数据层」那半红
|
||||
- M-F7:统计只报 active fp ⇒ 分组断言红
|
||||
- M-F8:把写侧宽度检查改成恒不触发 ⇒ 坏向量落库 ⇒ 硬报错断言红
|
||||
|
||||
★ F8 的两个入口要分清,否则会互相打架:
|
||||
- **调用方 bug**(显式传了一个宽度不对的向量):这是它自己的错,**必须抛**,
|
||||
否则它会以「有块、可召回不了」的形式沉默很久。
|
||||
- **provider 行为异常**(embedding 返回的宽度不对):用户没有做错任何事,
|
||||
按 W4 降级为「不嵌入」,块仍写入。把 provider 故障也抛给用户,
|
||||
等于让一次上游抖动变成写入失败。
|
||||
|
||||
#### 关于 `embedFailure`(记账,不是遗漏)
|
||||
|
||||
四态里 `noVector` / `unknownSpace` / `spaceMismatch` / `dimensionMismatch` 都是
|
||||
**库状态**,从行派生(D4)。`embedFailure` 不进四态,因为它是**单次调用事件** ——
|
||||
描述「这次没能拿到向量」,不是「库里存在这样的行」。
|
||||
|
||||
它有自己的位置:`recallBlocksHybrid` 的 `degraded`(值域
|
||||
`no_embedder` / `embed_error` / `aborted` / `vector_ready_unused`),
|
||||
由 W 组判据覆盖。把它塞进库状态计数会让「同一时刻读两次得到不同数字」,
|
||||
那正是 D4 要避免的。
|
||||
|
||||
#### F6 的三段口径(裁定:选 (a),措辞不再并列成互斥)
|
||||
|
||||
F6 曾被写成「仍可按文本调出、不参与向量召回」—— 读起来像互斥,其实说的是三个不同层。
|
||||
**「按文本调出」= store 级无空间浏览,不是生产召回路径**。
|
||||
|
||||
之所以不改 F2 去迁就 F6 的字面自洽:**F2 是 `fusion.go:495` 同款的安全缺陷修复,
|
||||
优先级高于 F6 的措辞**。所以 F6 改成三段并列,一段一条断言,同一个用例里全断言:
|
||||
|
||||
| 层 | 断言 | 若只测这一层会漏什么 |
|
||||
|---|---|---|
|
||||
| **数据层** | 指纹变更后旧块**仍在库中**:块数不减,按 fp 分组两行都在 | 只测「过滤掉了」会漏掉「数据被误删」 |
|
||||
| **浏览层** | store 级**不带空间参数**的文本查询仍能取到旧块 | 只测数据层证明不了它还可检索 |
|
||||
| **召回层** | **带 fingerprint** 的生产召回路径必须把旧块过滤掉(由 F2 的判据承担) | 只测浏览层会把 F2 挡回去 |
|
||||
|
||||
⇒ 判据形态:一个用例内同时断言三件事。少断言任何一段,另外两段都可能在无人察觉时退化。
|
||||
|
||||
### V. 向量与符号的融合
|
||||
|
||||
现有打分分带(`lib/recall.js`,phase A):
|
||||
|
||||
```
|
||||
[1.0+) exact 数字串布尔置顶
|
||||
[0..1) lexical / word coverage / CJK coverage
|
||||
```
|
||||
|
||||
phase B 必须把向量放进 **[0..1) 带**,不得越到 exact 带。
|
||||
|
||||
| # | 断言 | 依据 |
|
||||
|---|---|---|
|
||||
| V1 | 精确串命中仍**布尔置顶**,向量分只在同带内做平手微调(`1.0 + vec*0.01` 形态) | `fusion.go:427`;其上的注释记录了实测 bug:`sym=1.0×0.3=0.30` 输给 `vec=0.62×0.7+sym=0.333×0.3=0.534` |
|
||||
| V2 | 非精确候选:`score = vec*0.7 + sym*0.3`(无向量时退化为纯 sym) | `defaultWeights()` = `{Vector: 0.7, Symbol: 0.3}`(`fusion.go:66`) |
|
||||
| V3 | 向量分与符号分**可分别归因**(`vectorHit` / `symbolHit` / `why` 三元组),便于调试与模型解释 | `FusedHit` 的字段设计(`fusion.go:34-48` 的 `FusedHit`) |
|
||||
| V4 | tier3(纯泛化描述)从「已知缺口」变为**通过**,且缺口断言随之更新 | phase A 的 V2 checkpoint 显式断言了 tier3/4 是缺口——加向量后必须翻转成通过断言,否则等于没落地 |
|
||||
| V5 | 向量路召回但符号路零命中的块,**必须能上场**(不能被符号路截断掉) | `fuseCandidates` 明确注释:"符号路在全量块里找命中(不是只在向量候选里找)",并记录了 `&out[i]` 指针 bug(`fusion.go:367-374`) |
|
||||
| V6 | 查询侧与文档侧用不同 `side`(`query` / `document`) | HA http 协议有 `side` 字段(`http_embedder.go:76`),`PurposeQuery`/`PurposeDocument` 是两个枚举 |
|
||||
| V7 | 候选池在融合前**不截断**(先全量打分 → 融合 → 再按 topK 截断) | `RecallBlocksWithArbitration` 的顺序(`block_recall.go:229-255`),理由注释:「向量路已经把正确块挤掉的话,融合也无从救回」 |
|
||||
|
||||
**变异**:
|
||||
- M-V1:把 exact 改成 `0.7*vec + 0.3*sym`(把布尔降成权重)⇒ V1 红。**这是最重要的一条变异**——它就是 HA 记录过的那个真实 bug
|
||||
- M-V2:权重改成 0.5/0.5 ⇒ V2 的数值断言红
|
||||
- M-V5:只在向量候选里找符号命中 ⇒ 符号救回的块消失 ⇒ 红
|
||||
- M-V7:先按向量 topK 截断再融合 ⇒ 被挤掉的正确块回不来 ⇒ 红
|
||||
- M-V6:两侧都用 `query` ⇒ 断言 side 的用例红
|
||||
|
||||
### C. 中心化(`graph_centroid`)
|
||||
|
||||
| # | 断言 | 依据 |
|
||||
|---|---|---|
|
||||
| C1 | `Anomalous` 判定阈值 = 均值范数 ≥ **0.6** | `meanThreshold = 0.6`(`centroid.go:87`),依据「均值范数 0.9374 时去均值让分离度提升 7 倍」 |
|
||||
| C2 | 各向同性的库(`!Anomalous`)**不启用**校正 | `LoadCentroid` 的 `if !meta.Anomalous { return nil, false, nil }`(`centroid_store.go:205-209`) |
|
||||
| C3 | 带向量块数相对建中心时变化 > **20%** ⇒ 判 stale | `centroidRebuildRatio = 0.20`(`centroid_store.go:55`) |
|
||||
| C4 | 校正时**查询向量与块向量都去均值**,不能只处理一边 | `centerBoth`(`centroid_store.go:443-451`)+ 注释「只处理一边等于混两种量纲」 |
|
||||
| C5 | 「不校正」的**每一种原因可区分**:无中心 / 维度不符 / 已过期 / 各向同性 | `LoadCentroid` 有 4 条 `return nil, false, ...` 分支,HA 全部只返回 `(nil,false,nil)`,调用方无法区分 |
|
||||
| C6 | 中心按 fingerprint 分行存储,不跨空间复用 | 表 PK 是 `fingerprint` |
|
||||
|
||||
**变异**:
|
||||
- M-C1:阈值改 0.5 或 0.9 ⇒ 边界用例红
|
||||
- M-C4:只去查询向量的均值 ⇒ 同值对的余弦断言红
|
||||
- M-C5:把 4 种原因合成一个 `available: false` ⇒ 区分断言红
|
||||
|
||||
> **C4/C5 的一条实现注意**:HA 在 Go 里必须「离开 RLock 之后重建」(`centroid_store.go:198-203` 记录了自死锁的实测)。本插件是 `node:sqlite` 同步 API + JS 单线程,没有那个死锁形态;但**重建是 O(n·d),不能放在每轮召回的热路径上**。断言写成"C3 判定 stale 的那一轮**仍返回结果**(用未校正向量),重建异步进行"——与 HA 的 `scheduleCentroidRebuild` 语义同构。
|
||||
|
||||
### D. 降级与可见性(decision 3 的落地)
|
||||
|
||||
| # | 断言 | 依据 |
|
||||
|---|---|---|
|
||||
| D1 | 召回主路径**永不**因向量问题抛错:无 embedder / 调用失败 / 全量 fp 失配 ⇒ 仍返回符号路结果 | decision 3 的「保留跳过不报错」 |
|
||||
| D2 | 每一次跳过都进计数,**不留无计数的静默分支** | decision 3 的「不许只留日志」 |
|
||||
| D3 | 计数可经**工具**查询(`stablemem_stats` 或 `stablemem_introspect`) | decision 3 的「暴露到工具面(可查询)」 |
|
||||
| D4 | 计数从 **DB 派生**,不在内存里累加 | 内存累加重启即丢,且与真实库状态会漂移。HA 的 `BlockVectorStats` 就是扫库算的(`block_recall.go`) |
|
||||
| D5 | 常驻索引行(`systemPrompt.context`)展示**不可召回比例**,让模型每轮都能看到 | 我在上一轮提的;复用已有展示位,不新增机制 |
|
||||
| D6 | 计数分两层:**store 级**(存量状态,从 DB 派生)+ **上次召回级**(本次跳过了几条,per-call) | 两者回答不同问题:「库里有 40% 不可召回」vs「这次查询因失配丢了 3 条」 |
|
||||
|
||||
**D3 的工具输出形状(建议,待你确认字段名)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"blocks": { "total": 120, "with_vector": 98, "no_vector": 22 },
|
||||
"spaces": {
|
||||
"active": { "fingerprint": "http:jina-v5:2048:a1b2c3d4", "dim": 2048 },
|
||||
"by_fingerprint": [
|
||||
{ "fingerprint": "http:chineseclip:512:9f8e7d6c", "dim": 512, "blocks": 40, "status": "stale" },
|
||||
{ "fingerprint": "http:jina-v5:2048:a1b2c3d4", "dim": 2048, "blocks": 58, "status": "active" }
|
||||
],
|
||||
"no_fingerprint": 0
|
||||
},
|
||||
"centroid": { "state": "anomalous", "stale": false, "mean_length": 0.9374 },
|
||||
"last_recall": { "mode": "hybrid", "skipped_fp": 3, "skipped_dim": 0, "embed_error": null }
|
||||
}
|
||||
```
|
||||
|
||||
关键点:`status` 直接告诉用户**该做什么**(`stale` ⇒ 重嵌;`no_vector` ⇒ 回填;`anomalous=false` ⇒ 不需要校正)。
|
||||
|
||||
**变异**:
|
||||
- M-D1:全量 fp 失配时抛错 ⇒ 红
|
||||
- M-D2:把某个跳过分支的计数删掉 ⇒ 该分支的用例红
|
||||
- M-D3:工具不注册 `stablemem_stats` ⇒ 红
|
||||
- M-D4:计数改成内存累加 ⇒ 重启后计数归零的用例红
|
||||
- M-D6:只报 store 级不报 per-call ⇒ 红
|
||||
|
||||
### H. http provider(decision 1 的落地)
|
||||
|
||||
| # | 断言 | 依据 |
|
||||
|---|---|---|
|
||||
| H1 | 请求体字段与 HA 一致:`model` / `modality` / `side` / `text` / `data` / `mime` | `httpEmbedRequest`(`http_embedder.go:72-79`) |
|
||||
| H2 | 响应兼容两种形状:顶层 `embedding`,或 `data[0].embedding` | `httpEmbedResponse`(`http_embedder.go:81-87`) |
|
||||
| H3 | 非 2xx ⇒ 报错并进 `embed_error`,不吞成空向量 | HA 抛错(`http_embedder.go:141-142`),但上层 `recallByBlocks` 吞掉了;我们要「抛给召回层 + 召回层计降级」 |
|
||||
| H4 | 超时(默认 30s)可配,超时按 H3 处理 | `http_embedder.go:94-97` |
|
||||
| H5 | **零新依赖**:用 Node 内建 `fetch`,不加 onnx 相关包 | decision 1 |
|
||||
| H6 | 响应体大小上限(HA 是 4 MiB) | `http_embedder.go` 的 `io.LimitReader(resp.Body, 4<<20)` |
|
||||
|
||||
**变异**:
|
||||
- M-H1:字段名改成 `input` ⇒ 用录制响应的契约测试红
|
||||
- M-H2:只支持顶层 `embedding` ⇒ `data[0]` 形状的用例红
|
||||
- M-H3:非 2xx 返回空数组 ⇒ 红
|
||||
|
||||
### G. 守卫(不许伤到已交付的东西)
|
||||
|
||||
| # | 断言 |
|
||||
|---|---|
|
||||
| G1 | 整仓 `npm test` **225/225**、`npm run check` **12/12** 全绿(phase A 为其中的子集),语义不变(A1/A2 已覆盖) | 读数取自既有 V7 取证日志:`/tmp/v7-order-20261101-173334-t1..t3.log`(命令 `npm test`)各含 `# tests 225` / `# pass 225` / `# fail 0`;`/tmp/v7-order-20261101-173334-c1..c3.log`(命令 `npm run check`)各含 `12/12 checkpoints passed`。这些日志采集于提交 `591f340`,本提交不改数字口径 |
|
||||
| G2 | 未配置 embedder ⇒ 进程内无任何 HTTP 出站、无新依赖被加载 |
|
||||
| G3 | `stablemem_commit` 在 embedder 不可用时**仍能写入**(块入库、vector 留空,`no_vector` 计数 +1),不报错 |
|
||||
| G4 | 不引入 media/媒体块(`docs/feasibility.md` §5.4 的决定不变) |
|
||||
|
||||
**G3 变异**:embedder 不可用时拒绝 commit ⇒ 红。依据是 HA 的「无 provider 时块仍入库,检索退化为符号路,**不报错不编造**」(`memory-restructure-plan.md` §三 第 2 步判据)。
|
||||
|
||||
---
|
||||
|
||||
### W. 实施期新增(H 组落地时发现的判据,草案里没有)
|
||||
|
||||
这组不在原草案里。它们是实现过程中被真 bug 逼出来的,每条都对应一次
|
||||
「绿着但错」,所以留在这里作为历史与方法记录。
|
||||
|
||||
| # | 断言 | 逼出它的 bug | 变异 |
|
||||
|---|---|---|---|
|
||||
| W1/W5 | `exec.signal` 传到 `embedText`;abort 仍落盘(vector 空) | 写路径不接信号,取消后等满 adapter 超时 | 不传 signal ⇒ 红 |
|
||||
| W4 | 写侧维度校验:长度不符按 embed 失败处理 | 坏向量会入库,召回时被 skippedDim 静默跳过 | 删校验 ⇒ 红 |
|
||||
| W6 | 无 embedder 时 `commit` 与 `putBlock` 同形 | 九处调用点靠「无 await 也同步写完」的隐式事实 | — |
|
||||
| W7 | 空指纹在 embed **之前**判 | 原顺序白付一次网络往返 | 改为事后判 ⇒ 红 |
|
||||
| W8 | `rebuildCentroid` 从块重建(空间过滤 / 维度跳过 / 0.6 阈值) | `saveSpaceCentroid` 在 lib/ 里零调用点,中心表永久空 | 去空间过滤 / 去维度过滤 / 改阈值 ⇒ 红 |
|
||||
| **W9** | 真 adapter 下 caller abort 打断真实请求 | **`embedText(text)` 单形参,第二实参被 JS 静默丢弃** | 单参 + 去 fuse ⇒ 红 |
|
||||
| W9b | adapter 自身超时在有 caller signal 时仍生效 | 接 caller 信号时容易把自身 timeout 丢掉 | 只留 caller ⇒ 红 |
|
||||
| **W10** | 读路径(pre-step → contextForStep → recall → adapter)也接信号 | **读路径完全没有取消**,每次 pre-step 可卡 30s | 删 `options.signal` ⇒ 红,30001ms |
|
||||
| **W11** | 降级要在**渲染文本**里可见 | `degraded` 是 `enumerable:false`,对任何序列化都不可见 | 去掉提示行 ⇒ 红 |
|
||||
| **W11d** | 降级 + 零命中仍要注入提示,且无条目 | **两处短路**(contextForStep 与 renderRecall)各自都能吃掉它 | 恢复任一短路 ⇒ `W11d` 红(两条独立变异 W11d† / W11d†ctx) |
|
||||
| **W11e** | 无 embedder(出货默认)+ 零命中 ⇒ 注入必须为 `''` | **P0**:每轮白灌 ~213 字符恒定无条目的记忆头,训练模型跳过该段,废掉 W11 用意 | `no_embedder` 改回返回文案 ⇒ 红 |
|
||||
| **W12** | `aborted` 与 `embed_error` 标签与文案必须分开 | 取消被描述成「搜索失败」,会诱导无谓重试 | 合并标签 ⇒ 红 |
|
||||
| **W11f** | 零命中时任何模式都不许说「matches below」;有条目时必须指向条目(表驱动,覆盖未来未知模式) | **`aborted` 与 `default:` 兜底在零命中时仍承诺条目**;`default` 是未来任何新模式唯一会走的路,最不该编 | 删 `aborted` 分叉 ⇒ 红;删 `default:` 分叉 ⇒ 红 |
|
||||
|
||||
★ 这组里 W9/W10/W11d 的教训是同一个:**判据用替身/短路/自造形状时,
|
||||
验的是替身而不是真代码**。W9 之前用假 embedder(它自己监听 abort),
|
||||
W11d 之前两处短路互相掩盖,V3b 之前用自造 2 段 batch。
|
||||
补判据时先问一句:这条路径上有没有替身站在可能出错的那段前面。
|
||||
|
||||
★★ **变异纪律(同型犯了四次:W11†、W11d†a、W11d†b、W12†)**:
|
||||
变异体必须改 `lib/` 下的文件;一旦它动的是 `test/`、或断言的是测试里
|
||||
自己拼的局部函数,它就不是变异,是自测。这类「变异」在真回退下**保持绿**,
|
||||
却让台账按变异计数 —— 与 G5 那个恒假白名单同一种病:不能失败的守卫不是守卫。
|
||||
现行做法:真改源码 → 带 cache-busting 参数重新 import → 断言行为翻转 →
|
||||
还原文件;锚点匹配不到时**显式失败**,避免重构后静默失效。
|
||||
|
||||
## 2. 变异规则汇总
|
||||
|
||||
一句总纲:**每条断言至少一条变异能把它打红;打不红就说明这条判据没测到东西。**
|
||||
|
||||
必须能红的 12 条(按重要性):
|
||||
|
||||
| 变异 | 打红哪条 | 为什么最重要 |
|
||||
|---|---|---|
|
||||
| M-V1 精确串从布尔降成权重 | V1 | HA 真实踩过的 bug(0.300 输给 0.534) |
|
||||
| M-F2 删 fp 判断 | F2 | 跨空间混用的直接入口 |
|
||||
| M-D3 工具不注册 stats | D3 | decision 3 的红线 |
|
||||
| M-A2 无 embedder 时仍加向量分 | A2 | 防止「加向量顺手改了符号路」 |
|
||||
| M-D4 计数改内存累加 | D4 | 防止「重启就忘」 |
|
||||
| M-C4 只去一边均值 | C4 | 混量纲,分数全错但不报错 |
|
||||
| M-F5 空 fp 当匹配 | F5 | phase A 存量块的归类 |
|
||||
| M-F4 dim 并入 fp 计数 | F4 | 补救措施报错等于没报 |
|
||||
| M-V7 先截断再融合 | V7 | 正确块在融合前就被挤掉 |
|
||||
| M-H3 非 2xx 吞成空向量 | H3 | 静默降级 |
|
||||
| M-C5 合并 4 种不校正原因 | C5 | 用户不知道要不要修 |
|
||||
| M-G3 embedder 不可用时拒绝写入 | G3 | 记忆能力不得因向量服务挂掉而消失 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 已知会改动的接口(开工前要有心理准备)
|
||||
|
||||
| 改动 | 影响 |
|
||||
|---|---|
|
||||
| `recallBlocks` 同步 → `async` | 测试全部加 `await`;`contextForStep` 也变 async(handler 已是 async,无外部影响) |
|
||||
| `StableMem.recall()` → async | 同上 |
|
||||
| `putBlock` 增加 `vector` / `fingerprint` 列 | **需要给 `memory_blocks` 加两列**。表里已有数据(phase A 的块),要 `ALTER TABLE` 或重建 |
|
||||
| `graph_centroid` DDL 改列名对齐 HA | 现在免费(表空),见 §0.3 |
|
||||
| 新增 `stablemem_stats` 工具 | decision 3 的落点 |
|
||||
|
||||
> `memory_blocks` 加列这件事提示一个更深的问题:**phase A 的 DDL 已经是"已发布"的**。如果这个分支已经有人用了,加列必须走 `ALTER TABLE ... ADD COLUMN`(sqlite 支持且廉价),不能 `DROP` 重建。建议 phase B 第一件事就是加一个 `PRAGMA user_version` 的迁移框架——现在只有 0→1 一步,成本一天;等 L2 落地再补就贵了。
|
||||
|
||||
---
|
||||
|
||||
## 4. 不做的事(明确排除)
|
||||
|
||||
- 不背 ONNX(decision 1)
|
||||
- 不做媒体块向量(`Modality` 仍只有 `text`)
|
||||
- 不做 L2 冷文档向量(等 §5.2)
|
||||
- 不做跨进程/跨 agent 的向量共享
|
||||
- 不做向量压缩/量化(规模不到)
|
||||
- 不改仲裁(`arbitrate`)——它在 phase A 都没移植,phase B 也不顺带加。理由:仲裁是**同属性多值的时序**问题,与向量无关,混进来会让本阶段的判据失焦
|
||||
|
||||
---
|
||||
|
||||
## 5. 工作量(在 §7.1 阶段 B 原估 3–4 人天上修)
|
||||
|
||||
| 项 | 人天 |
|
||||
|---|---|
|
||||
| A 组:异步化 + golden fixture 固化 | 0.5 |
|
||||
| H 组:http provider + 契约测试(录制响应) | 1 |
|
||||
| schema 迁移框架 + `memory_blocks` 加列 + `graph_centroid` 对齐 | 0.5 |
|
||||
| V 组:融合 + 分带 | 1 |
|
||||
| F/D 组:三态归类 + 计数器 + `stablemem_stats` 工具 | 1 |
|
||||
| C 组:中心化 | 1 |
|
||||
| 判据与变异 | 1 |
|
||||
| **合计** | **6–7** |
|
||||
|
||||
上修原因:原估 3–4 人天没算 schema 迁移、`stablemem_stats` 工具(decision 3 新增)、以及异步化带来的全量测试改动。
|
||||
|
||||
---
|
||||
|
||||
## 6. 等你的三个回话
|
||||
|
||||
1. **§0.1** 指纹派生式:扩展(含 endpoint)还是与 HA 逐字同构?
|
||||
2. **§0.2** 空 fingerprint:fail-open(同 HA)还是 fail-closed(我建议)?
|
||||
3. **§0.4** embedding 调用失败是否一并进计数器(我建议进)?
|
||||
|
||||
外加一个不需要你拍板但我打算做的小修正:**§0.3 的 `graph_centroid` DDL 对齐 HA 列名**(现在是空的,免费)。
|
||||
|
||||
这三个定了我就按 A→H→G→D→V→F→C 的顺序写实现,每组写完立刻跑该组的变异。
|
||||
259
docs/phase-b-decision.md
Normal file
259
docs/phase-b-decision.md
Normal file
@ -0,0 +1,259 @@
|
||||
# Phase B 开工前决策建议(RD-1)
|
||||
|
||||
> 回应四项裁定 + 一个交叉点。**不含实现。**
|
||||
> 前序:`docs/phase-b-criteria.md`(判据草案,`0dfa6dc`)
|
||||
>
|
||||
> 本文只回答两件事:
|
||||
> 1. 你裁定的四条,我**逐条核对后确认可执行**,并标出各自的实现落点。
|
||||
> 2. **你提示的最大回归风险,我认为可以消除**——不必靠"小心地改 26 个测试",而是靠接口切分让它不发生。这是本文的重点。
|
||||
|
||||
---
|
||||
|
||||
## 1. 先说最重要的一条:异步化的回归风险可以结构性消除
|
||||
|
||||
你指出「异步化改动会碰 `recallBlocks` 这条已发布的召回主路径,26 个测试全量要改,这是本阶段最大的回归风险,不是工时问题」。
|
||||
|
||||
**我核了自己的草案,这个判断建立在我一个不必要的设计选择上。改掉它,风险归零。**
|
||||
|
||||
### 1.1 问题出在哪
|
||||
|
||||
我的草案 A1 写的是「`recallBlocks` 改为 `async`」。这是我图省事——把"要拿向量"和"要打分"混在了一个函数里。但这两件事**根本不需要在同一个函数里**。
|
||||
|
||||
看现有实现的分层(`lib/recall.js:223`):
|
||||
|
||||
```
|
||||
pool = FTS5 候选 + 包含兜底 ← 纯同步(读 sqlite)
|
||||
scored = 对 pool 打分排序 ← 纯同步(纯函数,无 IO)
|
||||
```
|
||||
|
||||
向量要插进来的位置只有一个:**给 pool 里的候选补一个 `vectorSim`**。补齐之后,打分逻辑完全不变,仍然是纯同步的。
|
||||
|
||||
### 1.2 所以切法应该是两段,不是一段
|
||||
|
||||
```
|
||||
recallBlocks(store, query, opts) ← 保持同步,签名不变,行为不变
|
||||
└─ 只做:pool 构建 + 打分 + 排序 + 截断
|
||||
新增可选入参 opts.vectorScores = Map<blockId, number>
|
||||
传了就参与打分,不传就是 phase A 的逐字节原行为
|
||||
|
||||
recallBlocksHybrid(store, embedder, query, opts) ← 新的 async 包装
|
||||
└─ await embedder.embed(query) → 取候选池 → 补齐 vectorScores
|
||||
→ 调用上面的同步 recallBlocks
|
||||
```
|
||||
|
||||
好处是**每一条都是硬性质,不是"我注意点"**:
|
||||
|
||||
| 性质 | 为什么成立 |
|
||||
|---|---|
|
||||
| **phase A 的 `recallBlocks` 一行不改** | 它只是多了一个可选入参,默认 `undefined` |
|
||||
| **9 个测试调用点一个不改** | 它们仍调同步版,仍拿同步返回值 |
|
||||
| **golden fixture(A2)自动成立** | 不传 `vectorScores` 走的就是原代码路径 |
|
||||
| **异步只出现在新函数里** | 旧的 API 面完全没被动过 |
|
||||
| **26 个测试不需要"改",只需要"加"** | 现有断言保持原样,新断言追加 |
|
||||
|
||||
### 1.3 实测的调用面(这就是全部改动量)
|
||||
|
||||
我把所有调用点数了一遍:
|
||||
|
||||
```
|
||||
生产代码:lib/index.js:124 return recallBlocks(...) → 改为 recallBlocksHybrid
|
||||
lib/index.js:140 const hits = this.recall(query) → 加 await
|
||||
lib/index.js:277 const injected = mem.contextForStep(...) → 加 await
|
||||
测试代码:test/store.test.js 5 处调用 → 全不改(用同步版)
|
||||
scripts/checkpoints.js 4 处调用 → 全不改(用同步版)
|
||||
test/plugin.test.js:119 `mem.contextForStep = () => ''` → 不改
|
||||
(async 化后 `await ''` 仍得 `''`,桩天然兼容)
|
||||
```
|
||||
|
||||
**净改动:生产代码 3 行。测试代码 0 行。**
|
||||
|
||||
我实测确认了这个桩的兼容性——现有变异测试把 `contextForStep` 替换成同步返回 `''` 的函数,async 化之后 `await` 一个非 Promise 值就是它本身,所以那条变异测试**继续有效且不需要动**。
|
||||
|
||||
### 1.4 对 guard 组的建议
|
||||
|
||||
你要求「guard 组提到外部依赖之前,先证明异步化没改坏 phase A 语义」。我同意顺序,但建议把判据改成可结构验证的形式:
|
||||
|
||||
| # | 原草案 | 建议改成 |
|
||||
|---|---|---|
|
||||
| A1 | 「`recallBlocks` 改 async 后 phase A 仍通过」 | **「`recallBlocks` 保持同步,签名与实现均不变;同时存在 `recallBlocksHybrid`」**——用一条断言钉住"同步版没有被偷偷改" |
|
||||
| A2 | 「无 embedder 时返回与 phase A 逐字节相同」 | 保留,且升级为 **golden fixture 对比**:固化 phase A 的 `[{id,score,exact,why}]`,phase B 跑同一 fixture 必须全等 |
|
||||
|
||||
附加一条新的守卫,这条比原 A1 更有价值:
|
||||
|
||||
| # | 断言 | 变异 |
|
||||
|---|---|---|
|
||||
| A3 | `recallBlocksHybrid` 在 **embedder 为 null 时**,其返回值与 `recallBlocks` 同参调用**逐字段相同**(含 `why` 字符串) | 让 hybrid 在无 embedder 时走一条略有差异的分支(哪怕只是 `why` 多个后缀)⇒ A3 红 |
|
||||
|
||||
A3 是真正防"两套实现漂移"的那条。因为切分之后存在两个入口,最大的新风险就是它们的行为不一致——A3 把它钉死。
|
||||
|
||||
### 1.5 结论
|
||||
|
||||
**你担心的 26 个测试全量改动,按这个切法不会发生。** 工时可从 6–7 人天里省回约 0.5 人天(原草案 A 组),但更重要的是:回归风险从"依赖执行纪律"变成"接口形状保证"。
|
||||
|
||||
我建议把这个切分直接写进判据草案的 A 组,替换掉原来的 A1。**需要你确认这个改法**——因为它改的是判据本身,不是实现细节。
|
||||
|
||||
---
|
||||
|
||||
## 2. 四条裁定的可执行性核对
|
||||
|
||||
### 2.1 指纹补 endpoint hash — 可执行,一处需明确
|
||||
|
||||
你定的格式:`http:<model>:<dim>:<sha256(endpoint)[:8]>`,并要求 endpoint 先规范化。
|
||||
|
||||
**可执行。** 落点在新的 embedder 窄接口后面,不在判据里出现 http 字段名(见 §3)。
|
||||
|
||||
需要你明确一个边界:**规范化具体包含哪些**?我准备按这四条,理由都是"同一服务写成不同字符串很常见":
|
||||
|
||||
| 规范化 | 例子 | 理由 |
|
||||
|---|---|---|
|
||||
| 去尾部 `/` | `http://h:8080/embed/` → `http://h:8080/embed` | 最常见的写法差异 |
|
||||
| host 小写 | `http://HOST:8080` → `http://host:8080` | DNS 大小写不敏感,但字符串比较敏感 |
|
||||
| 省略默认端口 | `http://h:80/embed` → `http://h/embed` | 同义 |
|
||||
| 去首尾空白 | — | 配置粘贴常见 |
|
||||
|
||||
**不做**:不解析/不重排 query string、不做路径归一(`/a/../b`)。理由:前者会掩盖真实差异,后者的实现成本与收益不成比例。
|
||||
|
||||
若你认可,我把这四条写成 F 组的一条判据(每条一个用例 + 一个变异:去掉该条规范化则指纹变化 ⇒ 红)。
|
||||
|
||||
### 2.2 空指纹 fail-closed + 四态 — 可执行,但有一处需要你知道
|
||||
|
||||
你定的四态:无向量 / 指纹空 / 指纹不符 / 向量化失败。**可执行,且比我草案的三态更准**——我把"向量化失败"漏在了三态之外(草案 §0.4 还单独问你),你直接合进来是对的,因为它同样是"用户下一步动作不同"。
|
||||
|
||||
需要你知道的一点:**这四态的扫描成本**。
|
||||
|
||||
`BlockVectorStats` 在 HA 里是**扫全表**算的(`block_recall.go` 的 `BlockVectorStats` 全程 `SELECT vector, fingerprint FROM memory_blocks` 再逐行 `decodeVector`)。在 dsh 里如果 `stablemem_stats` 每次都这么扫:
|
||||
|
||||
| 块数 | 估算(含 JSON 解析向量) |
|
||||
|---|---|
|
||||
| 1k | 可忽略 |
|
||||
| 10k | 首次调明显卡顿(HA 注释里 1391 块已"可接受但不优化") |
|
||||
| 100k | 不可接受 |
|
||||
|
||||
HA 的实现里有个细节值得注意:它为了算 `Dimensions` 必须逐行 `decodeVector`(`block_recall.go:303`,`:313` 的 `dimSet[len(vec)]`)。**维度统计与"不解析"是互斥的**,这点我上一版说漏了。
|
||||
|
||||
**建议的取舍**:
|
||||
|
||||
| 输出项 | 需要解析向量? | 默认 |
|
||||
|---|---|---|
|
||||
| `total` / `no_vector` | 否(`length(vector) > 0`) | ✔ 报 |
|
||||
| `no_fingerprint` / `stale` / `by_fingerprint.blocks` | 否(只看 `fingerprint` 列) | ✔ 报 |
|
||||
| `by_fingerprint.dim` | **是**(或读 `graph_centroid.dim`) | 从 agent 中心表读,不扫块 |
|
||||
| `vector_dimensions` 直方图 | **是** | 仅 `verbose: true` |
|
||||
|
||||
即:默认路径只扫 `fingerprint` + `length(vector)`,从 `graph_centroid` 取维度(中心表按 fp 一行,天然带 `dim`)。只有在 `verbose` 下才逐行解析来发现"同一 fp 下维度不一致"这种异常。
|
||||
|
||||
这样默认扫描是 O(n)、无 JSON 解析;10k 块约几十毫秒。
|
||||
|
||||
> D7:`stablemem_stats` 的**默认**路径不解析向量 JSON(用 `length(vector)` 判有/无,维度从 `graph_centroid` 取);`verbose: true` 才逐行解析。
|
||||
> **变异 1**:默认路径改为 `decodeVector` ⇒ 断言 10k 块 fixture 的执行时间上界(或直接注入一个 decodeVector 计数器断其为 0)⇒ 红
|
||||
> **变异 2**:从中心表取维度改成扫块 ⇒ 无中心时(第一次跑 stats)报维度为空 ⇒ 红
|
||||
|
||||
顺带一条边界:`graph_centroid` 可能为空(还没建中心)。此时 `by_fingerprint.dim` 报 `null` 而不是 `0`——`0` 会被误读成"维度是 0"。
|
||||
|
||||
### 2.3 `graph_centroid` 补 `stats` 列 — 已改为 HA 同构
|
||||
|
||||
这条是我草案 §0.3 自己承认的疏漏。对齐后的 DDL(`vector_count` / `vector` / `stats` / `built_at`)在 `stats` 里存 `centroidMeta` 同构 JSON:`{fingerprint, dim, block_count, vectored_count, mean_length, anomalous, built_at}`。
|
||||
|
||||
`anomalous` 必须在表里,因为 `LoadCentroid` 靠它决定是否启用校正(`centroid_store.go:205-209`)——没有它就无法复现判定。
|
||||
|
||||
**已按你的要求列为 phase B 第一件事。**
|
||||
|
||||
### 2.4 embedding 失败降级到纯 FTS5 — 可执行,且与我草案一致
|
||||
|
||||
草案 §0.4 和 D1 就是这个口径("召回主路径永不因向量问题抛错")。你裁定后它从"待拍板"变成"已定",我把它从 §0.4 的疑问项移到 D 组的硬判据。
|
||||
|
||||
一个补充:**降级也要带原因**。D 组的 `last_recall.mode` 我准备写成三值而非 bool:
|
||||
|
||||
| mode | 含义 |
|
||||
|---|---|
|
||||
| `hybrid` | 向量 + 符号都参与了 |
|
||||
| `symbol_only` | 走了符号路(原因见 `degraded_reason`) |
|
||||
| `symbol_only` + `degraded_reason: 'no_embedder'` | 没配向量 |
|
||||
| `symbol_only` + `degraded_reason: 'embed_error'` | 配了但调失败 |
|
||||
| `symbol_only` + `degraded_reason: 'all_mismatched'` | 配了、调成功,但库里没有可比块 |
|
||||
|
||||
这三种 `symbol_only` 对用户的意义不同:没配 = 没启用;调失败 = 服务挂了;全失配 = 该重嵌。合成一个 bool 就又回到"降级不可见"了。
|
||||
|
||||
---
|
||||
|
||||
## 3. 关于「向量来源可晚点定」——我建议现在就把接口焊死
|
||||
|
||||
你说向量来源可以晚点定,只要把 embedder 收在单方法窄接口后面、判据里不出现 http 专属字段名。**同意,并且建议再收紧一点。**
|
||||
|
||||
```js
|
||||
/**
|
||||
* 唯一的向量能力接口。整个插件只依赖这三个成员。
|
||||
* @typedef {object} Embedder
|
||||
* @property {(text: string) => Promise<number[]>} embedText 查询侧
|
||||
* @property {() => { fingerprint: string, dimension: number }} info
|
||||
* @property {() => Promise<void>} [close]
|
||||
*/
|
||||
```
|
||||
|
||||
三点说明:
|
||||
|
||||
1. **`embedText` 只有 text**,没有 media。phase B 的 `Modality` 仍只有 `text`(草案 §4 已排除媒体块),所以接口不需要 reserve 媒介参数。等到真做媒体时再加,比现在猜一个签名好。
|
||||
2. **`info()` 同步返回** fingerprint 与 dimension。这让"维度校验"能在**调用 embedText 之前**做掉一部分(配置维度不符可以立刻硬报错,不用等一次网络往返)。
|
||||
3. 判据里凡是断言 http 契约的(H 组 H1/H2/H6),**全部标成"provider 适配层的契约测试"**,与 V/F/C/D 组解耦。这样换本地 ONNX 时,H 组整组替换,V/F/C/D 不动。
|
||||
|
||||
**我建议加一条守卫**(G 组):
|
||||
|
||||
> G5:除 `lib/embed/http.js` 这一个文件外,`lib/` 下任何文件**不得出现** `http`、`endpoint`、`fetch`、`Bearer`、`model` 等 provider 专属标识。
|
||||
> **实现方式**:一条源码 grep 断言(像 HA 的 `grep -rn '"agentcli"\|"terminal"'` 那条内核插件名判据一样)。
|
||||
> **变异**:在 `recall.js` 里写一句 `import ... from './embed/http.js'` 之外的 http 细节 ⇒ 红。
|
||||
|
||||
这条比"约定不出现"强,因为它是机器可查的。
|
||||
|
||||
---
|
||||
|
||||
## 4. 交叉点:6–7 人天要不要投
|
||||
|
||||
**我的建议:投,但要按 §1 的切法重新算,实际是 5.5–6.5 人天。**
|
||||
|
||||
| 项 | 原草案 | 按 §1 切分后 |
|
||||
|---|---|---|
|
||||
| A 组 异步化 + golden fixture | 0.5 | 0.5(fixture 仍要做,但异步化的改动量降到 3 行) |
|
||||
| H 组 http provider + 契约测试 | 1 | 1 |
|
||||
| schema 迁移框架 + 加列 + centroid 对齐 | 0.5 | 0.5 |
|
||||
| V 组 融合 + 分带 | 1 | 1 |
|
||||
| F/D 组 四态归类 + 计数器 + stats 工具 | 1 | 1(D7 的 `length(vector)` + 中心表取维度不额外加时) |
|
||||
| C 组 中心化 | 1 | 1 |
|
||||
| 判据与变异 | 1 | 1(A3/A5 两条新增,被 §1 省下的测试改动抵消) |
|
||||
| **合计** | **6–7** | **5.5–6.5** |
|
||||
|
||||
**支持投的理由**(你关心的不是工时,我按同等分量给反对的可能):
|
||||
|
||||
- phase A 交付的是"能存的记忆",phase B 交付的是"能找得到的记忆"。只存不找,这个插件的价值边界就是"精确串查询"——那其实用一个 FTS 索引就够了,不需要块、边、中心化这一整套。
|
||||
- 已定死的四条裁定(endpoint 指纹、四态、centroid 列、失败降级)**都是为 phase B 才存在的**。不投这 6 天,这四条裁定就悬空。
|
||||
- 增量风险可控:§1 之后,phase A 的代码路径基本没被动,新逻辑集中在新文件。
|
||||
|
||||
**反对该投的理由**(我认为不足以否掉,但列出来):
|
||||
|
||||
- phase A 至今没有真实使用数据。6 天投下去,如果实际没人用 `stablemem_commit`,向量侧就是空转。**这一条我认为是最值得你先确认的**——是否有真实使用中的 dsh 会话在产生块?
|
||||
- tier3(纯泛化描述)在 HA 里即使有向量也只是"目标块排名 389/2686"(`docs/zh/recall-capability-tiers.md`)。也就是说 phase B 交付后,tier3 从"完全不可用"变成"勉强可用",不是"好用"。这是能力上限,不是实现问题。
|
||||
|
||||
如果你的判断是"先看有没有人用",那我建议一个更便宜的中间态:**只做 H 组 + schema + F 组(向量写入与四态可见),不做 V/C 组(不参与召回)**。约 2.5 人天,拿到"块有向量、统计能看、空间切换可见",但不改召回主路径。等有使用数据再上 V/C。**这条中间路我愿意做,但它不满足你已裁定的"phase B"目标**,所以需要你明确。
|
||||
|
||||
---
|
||||
|
||||
## 5. 需要你回话的(4 项)
|
||||
|
||||
| # | 事项 | 我的建议 | 不定的后果 |
|
||||
|---|---|---|---|
|
||||
| 1 | **§1 的接口切分**(`recallBlocks` 保持同步 + 新增 `recallBlocksHybrid`),替换草案 A1 | 采纳 | 按原 A1 做,26 个测试全量改动,回归风险回到"靠纪律" |
|
||||
| 2 | **§2.1 endpoint 规范化的四条边界** | 采纳四条,不做 query 重排与路径归一 | 指纹对"同一服务不同写法"过敏,触发无谓全量重嵌 |
|
||||
| 3 | **§2.2 D7**(stats 默认不解析向量,维度取自中心表) | 采纳 | 10k 块以上 stats 工具变卡;或将来被迫改接口 |
|
||||
| 4 | **§4 交叉点**:投 5.5–6.5 做完整 phase B,还是投 2.5 做中间态(写入+可见,不改召回) | 完整 phase B;但**建议你先确认是否有真实使用中的会话在产生块** | 无 |
|
||||
|
||||
另有三项**不需要你回话、我按已定裁定执行**:§2.3 centroid 列对齐(你已裁定)、§2.4 失败降级(你已裁定)、§3 的 G5 源码 grep 守卫(我建议加,属实现细节)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 我这一轮的自我修正记录
|
||||
|
||||
写这份建议时发现自己草案里两处该改的地方,一并记下,避免下一轮重复:
|
||||
|
||||
1. **A1 把"异步化"和"保持兼容"绑在一起了**(§1)。这是设计偷懒,不是必要复杂度。切分之后两者解耦。
|
||||
2. **§0.4 把"embedding 失败"当成待拍板项**(§2.2)。你的四点裁定里直接把它并进四态说明它本来就不该单独问——它和"指纹失配"是同一类问题(静默降级的可见性),归类是我做窄了。
|
||||
|
||||
已完成的一条:§0.3 的 centroid DDL 疏漏是我自己发现的,已按你要求列为第一件事。
|
||||
244
lib/embed/http.js
Normal file
244
lib/embed/http.js
Normal file
@ -0,0 +1,244 @@
|
||||
/**
|
||||
* HTTP embedding adapter — the ONLY file allowed to know a transport.
|
||||
*
|
||||
* Group G's `G5` guard enforces that: no other file under `lib/` may contain
|
||||
* `fetch(`, `Authorization`, `Bearer`, `http(s)://`, `model_dir`, or `api_key`.
|
||||
* Keeping the transport confined here is what makes "swap HTTP for a local ONNX
|
||||
* runner" a one-file change instead of an audit of the whole plugin.
|
||||
*
|
||||
* ## Protocol
|
||||
*
|
||||
* Deliberately identical to HologramAgent's `http` provider
|
||||
* (`internal/memory/vector/http_embedder.go:72-87`) so an existing HA sidecar can
|
||||
* be pointed at without a shim:
|
||||
*
|
||||
* ```jsonc
|
||||
* // request
|
||||
* { "model": "...", "modality": "text", "side": "query",
|
||||
* "text": "...", "data": "<base64>", "mime": "image/png" }
|
||||
* // response — either shape is accepted
|
||||
* { "embedding": [ ... ] }
|
||||
* { "data": [ { "embedding": [ ... ] } ] }
|
||||
* ```
|
||||
*
|
||||
* ## Fingerprint
|
||||
*
|
||||
* `http:<model>:<dim>:<sha256(normalised endpoint)[:8]>`
|
||||
*
|
||||
* HA derives `http:<model>:<dim>` (`http_embedder.go:99`) — model and dimension
|
||||
* only. That is unsafe once an endpoint is configurable: two deployments of the
|
||||
* same model name at the same dimension produce **the same fingerprint but
|
||||
* different vectors** (different weights, different hardware, a different
|
||||
* region), so blocks from both spaces compare silently and wrongly. Adding the
|
||||
* endpoint hash closes that hole. The digest is truncated to 8 hex chars: it
|
||||
* only needs to distinguish a handful of configured endpoints, not to resist an
|
||||
* attacker.
|
||||
*
|
||||
* Normalisation happens **before** hashing so that cosmetic differences in the
|
||||
* configuration string do not each trigger a full re-embed:
|
||||
*
|
||||
* - strip surrounding whitespace
|
||||
* - lowercase the scheme and the host
|
||||
* - drop a trailing `/` from the path
|
||||
* - drop an explicit default port (`:80` for http, `:443` for https)
|
||||
*
|
||||
* It deliberately does NOT reorder query strings or resolve `..` in paths:
|
||||
* those differences are usually real, and hiding them would mask a genuine
|
||||
* misconfiguration.
|
||||
*
|
||||
* @module dsh-memory/embed/http
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/** Request timeout; HA's default is 30s (`http_embedder.go:95-97`). */
|
||||
const DEFAULT_TIMEOUT_MS = 30_000
|
||||
|
||||
/** Response body cap. HA uses 4 MiB (`http_embedder.go:136`). */
|
||||
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* Normalise an endpoint string for fingerprinting.
|
||||
*
|
||||
* @param {string} endpoint
|
||||
* @returns {string} the normalised form (empty string when input is unusable)
|
||||
*/
|
||||
export function normalizeEndpoint(endpoint) {
|
||||
const raw = String(endpoint ?? '').trim()
|
||||
if (raw === '') return ''
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(raw)
|
||||
} catch {
|
||||
// Not a parseable URL: fall back to the trimmed string so the fingerprint is
|
||||
// still stable, and let the request itself fail loudly later.
|
||||
return raw
|
||||
}
|
||||
|
||||
// URL already lowercases protocol and host; be explicit about it so the
|
||||
// behaviour survives a switch away from WHATWG URL.
|
||||
const scheme = url.protocol.toLowerCase()
|
||||
const host = url.hostname.toLowerCase()
|
||||
const defaultPort = scheme === 'https:' ? '443' : scheme === 'http:' ? '80' : ''
|
||||
const port = url.port === defaultPort ? '' : url.port
|
||||
const path = url.pathname.replace(/\/+$/, '')
|
||||
const search = url.search
|
||||
|
||||
return `${scheme}//${host}${port === '' ? '' : `:${port}`}${path}${search}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the vector-space fingerprint.
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {string} input.endpoint configured endpoint (normalised internally)
|
||||
* @param {string} [input.model]
|
||||
* @param {number} input.dimension
|
||||
* @param {string} [input.fingerprint] explicit override (HA has the same escape hatch)
|
||||
* @returns {string}
|
||||
*/
|
||||
export function deriveFingerprint({ endpoint, model = '', dimension, fingerprint }) {
|
||||
if (typeof fingerprint === 'string' && fingerprint !== '') return fingerprint
|
||||
const normalised = normalizeEndpoint(endpoint)
|
||||
const digest = createHash('sha256').update(normalised, 'utf8').digest('hex').slice(0, 8)
|
||||
return `http:${model}:${dimension}:${digest}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an embedder over an HTTP endpoint.
|
||||
*
|
||||
* Returns the same three-member shape `recallBlocksHybrid` expects — nothing
|
||||
* here leaks into scoring, storage, or grouping.
|
||||
*
|
||||
* @param {object} config
|
||||
* @param {string} config.endpoint
|
||||
* @param {number} config.dimension
|
||||
* @param {string} [config.model]
|
||||
* @param {string} [config.apiKey]
|
||||
* @param {string} [config.fingerprint] override
|
||||
* @param {number} [config.timeoutMs]
|
||||
* @param {'query'|'document'} [config.side] prompt side for TEXT embedding
|
||||
* (default `query`).
|
||||
*
|
||||
* ★ V6 GAP — TEXT + `document` is INTERFACE-ONLY.
|
||||
* Go assigns `Side: "query"` for text (`internal/memory/vector/http_embedder.go:105`)
|
||||
* and `Side: "document"` only for images (`:109`). It never sends text with
|
||||
* `document`, so there is no kernel precedent for what a text-document vector
|
||||
* means, and no criterion here claims one. This option pins the WIRE SHAPE the
|
||||
* adapter can produce; the SEMANTICS are deliberately unasserted.
|
||||
* @returns {{ embedText: (text: string, signal?: AbortSignal) => Promise<number[]>,
|
||||
* info: () => {fingerprint: string, dimension: number},
|
||||
* close: () => Promise<void> }}
|
||||
*/
|
||||
export function createHttpEmbedder(config) {
|
||||
const {
|
||||
endpoint,
|
||||
dimension,
|
||||
model = '',
|
||||
apiKey = '',
|
||||
fingerprint,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
side = 'query',
|
||||
} = config ?? {}
|
||||
|
||||
// Only the two sides Go defines are accepted (`pkg/embedding/embedding.go:34-35`).
|
||||
// Anything else would be an invented protocol value the provider cannot map.
|
||||
if (side !== 'query' && side !== 'document') {
|
||||
throw new Error(`dsh-memory: embed side must be 'query' or 'document', got ${String(side)}`)
|
||||
}
|
||||
|
||||
if (typeof endpoint !== 'string' || endpoint.trim() === '') {
|
||||
throw new Error('dsh-memory: http embedder requires an endpoint')
|
||||
}
|
||||
if (!Number.isInteger(dimension) || dimension <= 0) {
|
||||
throw new Error(`dsh-memory: http embedder requires a positive integer dimension, got ${dimension}`)
|
||||
}
|
||||
|
||||
const normalisedEndpoint = normalizeEndpoint(endpoint)
|
||||
// ★ Computed ONCE and reused. The same string must reach both
|
||||
// `memory_blocks.fingerprint` and `graph_centroid.fingerprint`; deriving it
|
||||
// per call risks two tables holding two spellings, which makes
|
||||
// `LoadCentroid` miss silently (no error, centering just never applies).
|
||||
const fp = deriveFingerprint({ endpoint: normalisedEndpoint, model, dimension, fingerprint })
|
||||
|
||||
let closed = false
|
||||
const info = () => ({ fingerprint: fp, dimension })
|
||||
|
||||
async function embedText(text, signal) {
|
||||
if (closed) throw new Error('dsh-memory: http embedder closed')
|
||||
// ★ TWO cancellations must both apply, and neither may replace the other:
|
||||
// · the caller's (a cancelled turn must stop immediately), and
|
||||
// · this adapter's own timeout (a wedged provider must still be bounded).
|
||||
// `AbortSignal.any` fuses them; the repo already requires Node >= 22.5, where
|
||||
// it is available. Writing a hand-rolled fuse here would be a second place to
|
||||
// get the "one aborted before we start listening" case wrong.
|
||||
//
|
||||
// The previous version took `text` only, so the caller's signal was silently
|
||||
// dropped by JS — the call ran to the adapter's own timeout even though the
|
||||
// user had already cancelled. A dropped arrow is worse than no arrow: the
|
||||
// call site reads as if cancellation were handled.
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(new Error('dsh-memory: embed request timed out')), timeoutMs)
|
||||
const fused =
|
||||
signal === undefined || signal === null
|
||||
? controller.signal
|
||||
: AbortSignal.any([signal, controller.signal])
|
||||
try {
|
||||
const response = await fetch(normalisedEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(apiKey === '' ? {} : { Authorization: `Bearer ${apiKey}` }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
modality: 'text',
|
||||
// go: http_embedder.go:105 (`query`) / :109 (`document`, image only).
|
||||
// The value travels verbatim; V6 pins the shape, not the semantics.
|
||||
side,
|
||||
text: String(text ?? ''),
|
||||
}),
|
||||
signal: fused,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '')
|
||||
throw new Error(`dsh-memory: embed endpoint returned ${response.status}: ${body.slice(0, 200)}`)
|
||||
}
|
||||
|
||||
const raw = await response.text()
|
||||
if (raw.length > MAX_RESPONSE_BYTES) {
|
||||
throw new Error(`dsh-memory: embed response exceeds ${MAX_RESPONSE_BYTES} bytes`)
|
||||
}
|
||||
const payload = JSON.parse(raw)
|
||||
|
||||
// Both shapes are accepted (HA: http_embedder.go:81-87).
|
||||
const vector = Array.isArray(payload?.embedding)
|
||||
? payload.embedding
|
||||
: Array.isArray(payload?.data?.[0]?.embedding)
|
||||
? payload.data[0].embedding
|
||||
: undefined
|
||||
|
||||
if (!Array.isArray(vector)) {
|
||||
throw new Error('dsh-memory: embed response carries no embedding array')
|
||||
}
|
||||
if (vector.length !== dimension) {
|
||||
// Hard error, never truncate or pad: a silently mis-sized vector is a
|
||||
// meaningless score that looks plausible (HA: block_recall.go:135-138).
|
||||
throw new Error(`dsh-memory: embed dimension ${vector.length}, want ${dimension}`)
|
||||
}
|
||||
return vector
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
embedText,
|
||||
info,
|
||||
async close() {
|
||||
closed = true
|
||||
},
|
||||
}
|
||||
}
|
||||
1148
lib/index.js
Normal file
1148
lib/index.js
Normal file
File diff suppressed because it is too large
Load Diff
173
lib/loop.js
Normal file
173
lib/loop.js
Normal file
@ -0,0 +1,173 @@
|
||||
/**
|
||||
* HologramAgent loop: surface recall as a SYSTEM section instead of history.
|
||||
*
|
||||
* Why this exists
|
||||
* ---------------
|
||||
* `dsh-memory` injects per-turn recall through the `agent/pre-step` waterfall,
|
||||
* and that hook can only return `messages`. Whatever it adds therefore lands in
|
||||
* conversation history as a user message — the model reads its own memory as if
|
||||
* a user had said it. `systemPrompt.context` cannot fix this either: that hook
|
||||
* runs before the turn text is known (documented in dsh-memory's own module
|
||||
* note), so it can only render input-INDEPENDENT text.
|
||||
*
|
||||
* The loop's own `preStep` is the first place that has BOTH the assembled
|
||||
* prompt and the claimed messages for this turn. So the swap point is here:
|
||||
* let the existing pre-step hooks run unchanged, then move the recall block out
|
||||
* of `messages` and into `assembly.sections`. The memory becomes part of the
|
||||
* system prompt for that turn, and the user message never enters history.
|
||||
*
|
||||
* Coupling
|
||||
* --------
|
||||
* None, deliberately. We do not import the memory plugin or match its name; we
|
||||
* recognise the *shape* every snapshot-style injector already uses
|
||||
* (`source.sections = [{ name, text }]`), which is the same shape
|
||||
* `dsh-time-context` emits. Anything a plugin chose to label that way is
|
||||
* promoted; everything else passes through untouched.
|
||||
*/
|
||||
|
||||
import { AgentLoop } from '@deepseek-ai/dsh-agent-loop'
|
||||
|
||||
/** Suffix appended to the promoted section name, keeping provenance visible. */
|
||||
const PROMOTED_SUFFIX = ':system'
|
||||
|
||||
/**
|
||||
* Split one message's `source.sections` out of the message itself.
|
||||
*
|
||||
* A snapshot carrier is recognised by ONE of two canonical spellings, both of
|
||||
* which real producers emit:
|
||||
*
|
||||
* 1. `content: []` — a bare carrier. The sections are the whole payload.
|
||||
* 2. `content: [{ type: 'text', text }]` — what this plugin itself emits
|
||||
* (`lib/index.js:932`): one text block whose text is the sections' text
|
||||
* joined by `\n\n` (or, from other producers, concatenated with no
|
||||
* separator). Both conventions are accepted because the joining choice
|
||||
* belongs to the producer, not to this loop.
|
||||
*
|
||||
* ★ The invariant is "move the text, do not rewrite it". So when content is
|
||||
* present, it is promoted ONLY if the rendered blocks equal the sections'
|
||||
* text under one of those two conventions, and the promoted section text is
|
||||
* the *rendered content*, not a re-join of the sections. That makes
|
||||
* "relocation" assertable in a test (`promoted.text === original content
|
||||
* text`) and leaves no room for a silent edit. Anything else — a differing
|
||||
* text, a non-text block, a runtime-context snapshot whose content carries a
|
||||
* fixed preamble the sections do not — stays in history untouched.
|
||||
*
|
||||
* History: the previous rule was "content non-empty ⇒ reject", derived from an
|
||||
* assumption ("content means the plugin wants the user to see it") that is
|
||||
* false for this plugin's own output, so on the real path the promotion could
|
||||
* never fire. See `test/loop.test.js` S1–S7.
|
||||
*
|
||||
* @param {object} message a user message from a pre-step decision
|
||||
* @returns {{text: string, name: string} | null}
|
||||
*/
|
||||
function promotedSection(message) {
|
||||
const source = message === null || typeof message !== 'object' ? undefined : message.source
|
||||
if (source === null || typeof source !== 'object') return null
|
||||
if (source.form !== 'snapshot') return null
|
||||
if (!Array.isArray(source.sections) || source.sections.length === 0) return null
|
||||
const parts = []
|
||||
for (const section of source.sections) {
|
||||
if (section !== null && typeof section === 'object' && typeof section.text === 'string' && section.text !== '') {
|
||||
parts.push(section.text)
|
||||
}
|
||||
else if (typeof section === 'string' && section !== '') parts.push(section)
|
||||
}
|
||||
if (parts.length === 0) return null
|
||||
const name = typeof source.kind === 'string' && source.kind !== '' ? source.kind : 'memory'
|
||||
|
||||
const blocks = Array.isArray(message.content) ? message.content : []
|
||||
if (blocks.length === 0) return { text: parts.join('\n\n'), name }
|
||||
|
||||
// Any non-text block means this is not a carrier of prompt text: an image
|
||||
// (or a block whose text is not a string) would be silently destroyed by a
|
||||
// text-only move, so refuse the whole message.
|
||||
for (const block of blocks) {
|
||||
if (block === null || typeof block !== 'object') return null
|
||||
if (block.type !== 'text' || typeof block.text !== 'string') return null
|
||||
}
|
||||
|
||||
// Both canonical joins are accepted; the promoted text is the RENDERED
|
||||
// content, which is what the model would have read, byte for byte.
|
||||
const rendered = blocks.map((block) => block.text).join('')
|
||||
const glued = parts.join('')
|
||||
const spaced = parts.join('\n\n')
|
||||
if (rendered !== glued && rendered !== spaced) return null
|
||||
return { text: rendered, name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite one pre-step decision so snapshot sections live in the prompt.
|
||||
*
|
||||
* The returned object is a NEW decision: `super.step()` keeps the one it
|
||||
* received, and the loop's phase bookkeeping reads `assembly` off whatever it
|
||||
* is handed. Mutating in place would work today and would silently break the
|
||||
* first time the loop reuses a decision.
|
||||
*
|
||||
* @param {object} decision the decision returned by the pre-step waterfall
|
||||
* @returns {object} the same decision shape with promoted sections moved
|
||||
*/
|
||||
export function promoteSnapshotSections(decision) {
|
||||
if (decision === null || typeof decision !== 'object') return decision
|
||||
if (decision.kind === 'reject') return decision
|
||||
const messages = Array.isArray(decision.messages) ? decision.messages : []
|
||||
const assembly = decision.assembly
|
||||
if (assembly === null || typeof assembly !== 'object' || !Array.isArray(assembly.sections)) return decision
|
||||
|
||||
const kept = []
|
||||
const promoted = []
|
||||
for (const message of messages) {
|
||||
const section = promotedSection(message)
|
||||
if (section === null) kept.push(message)
|
||||
else promoted.push(section)
|
||||
}
|
||||
if (promoted.length === 0) return decision
|
||||
|
||||
const sections = [...assembly.sections]
|
||||
const seen = new Set(sections.map((section) => section.name))
|
||||
for (const section of promoted) {
|
||||
// A name collision means some other producer already owns that slot. The
|
||||
// prompt layer throws on duplicates, so suffix rather than overwrite:
|
||||
// dropping the content would hide memory with no visible failure.
|
||||
let name = `${section.name}${PROMOTED_SUFFIX}`
|
||||
for (let n = 2; seen.has(name); n += 1) name = `${section.name}${PROMOTED_SUFFIX}${n}`
|
||||
seen.add(name)
|
||||
// `interpolate: false` is mandatory, not cosmetic: recalled notes are free
|
||||
// text and may legitimately contain `{{...}}`, which the strict interpolator
|
||||
// rejects with `unknown prompt variable` (measured — dsh-memory hit this).
|
||||
sections.push({ name, text: section.text, interpolate: false })
|
||||
}
|
||||
|
||||
return { ...decision, messages: kept, assembly: { ...assembly, sections } }
|
||||
}
|
||||
|
||||
/**
|
||||
* The headless AgentLoop, with snapshot sections promoted into the prompt.
|
||||
*
|
||||
* No constructor and no static `inject`: the parent already registers the
|
||||
* turnBoundary + inbox projections, calls `agents.setFactory(this)`, and
|
||||
* declares the `{{provider}}` / `{{model}}` variables. Re-declaring any of them
|
||||
* here would be the bug — `setFactory` throws if a factory is already
|
||||
* registered, so a second registration is exactly what breaks startup.
|
||||
*/
|
||||
export class HologramAgentLoop extends AgentLoop {
|
||||
/**
|
||||
* Prepare one agent, then wrap its pre-step seam.
|
||||
*
|
||||
* Patching the *instance* (not the prototype) is deliberate: the prototype is
|
||||
* shared with every agent the base class creates, including ones this plugin
|
||||
* must not touch. An own property shadows it for this agent only.
|
||||
*
|
||||
* @param {...unknown} args forwarded verbatim to the parent
|
||||
* @returns {{agent: object, signal: AbortSignal, publish: Function}}
|
||||
*/
|
||||
prepare(...args) {
|
||||
const prepared = super.prepare(...args)
|
||||
const agent = prepared === null || typeof prepared !== 'object' ? undefined : prepared.agent
|
||||
if (agent === undefined || typeof agent.preStep !== 'function') return prepared
|
||||
const original = agent.preStep.bind(agent)
|
||||
agent.preStep = async (target, position) => promoteSnapshotSections(await original(target, position))
|
||||
return prepared
|
||||
}
|
||||
}
|
||||
|
||||
export default HologramAgentLoop
|
||||
140
lib/loop/assistant-stream.js
Normal file
140
lib/loop/assistant-stream.js
Normal file
@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Folds one model attempt into one compact stream plus ordered transient frames.
|
||||
*
|
||||
* Reproduced from `dsh-agent-loop/lib/types/index.js:352-473` (0.2.1-rc.2, MIT)
|
||||
* because `dsh-agent-loop` does not export it, and there is no behaviour to
|
||||
* change here: the framing has to match what `dsh-headless` and the session
|
||||
* title plugin read off `agent/assistant-stream` byte for byte.
|
||||
*
|
||||
* @module dsh-memory/lib/loop/assistant-stream
|
||||
*/
|
||||
|
||||
import { AssistantStreamAccumulator, BlockAssembler, LlmAttemptId } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
export class AssistantStreamAttempt {
|
||||
/** @type {() => number} */
|
||||
nextRevision
|
||||
/** @type {number} */
|
||||
turn
|
||||
/** @type {number} */
|
||||
step
|
||||
/** @type {(frame: object) => void} */
|
||||
emit
|
||||
accumulator = new AssistantStreamAccumulator()
|
||||
assembler = new BlockAssembler()
|
||||
index = 0
|
||||
terminal = false
|
||||
/** Attempt identity unique within this Agent lifecycle. */
|
||||
attemptId
|
||||
|
||||
/** Whether this started attempt has emitted its terminal frame. */
|
||||
get ended() {
|
||||
return this.terminal
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} sessionId identity embedded only in the Agent-lifecycle-local attempt id
|
||||
* @param {number} attempt attached-Session-local attempt counter
|
||||
* @param {() => number} nextRevision allocates the next emitted frame revision
|
||||
* @param {number} turn durable turn owning the request
|
||||
* @param {number} step durable step owning the request
|
||||
* @param {(frame: object) => void} emit agent-scoped notification publisher
|
||||
*/
|
||||
constructor(sessionId, attempt, nextRevision, turn, step, emit) {
|
||||
this.nextRevision = nextRevision
|
||||
this.turn = turn
|
||||
this.step = step
|
||||
this.emit = emit
|
||||
this.attemptId = LlmAttemptId(`${sessionId}:${attempt}`)
|
||||
}
|
||||
|
||||
/** Publish the opening marker before the first delivered chunk. */
|
||||
start() {
|
||||
this.emit({
|
||||
type: 'start',
|
||||
attemptId: this.attemptId,
|
||||
revision: this.nextRevision(),
|
||||
turn: this.turn,
|
||||
step: this.step,
|
||||
})
|
||||
}
|
||||
|
||||
/** Snapshot one chunk once, then feed durable compaction, assembly, and live publication. */
|
||||
push(chunk) {
|
||||
const timed = this.accumulator.push({ time: Date.now(), chunk })
|
||||
this.assembler.push(timed.chunk)
|
||||
this.emit({
|
||||
type: 'chunk',
|
||||
attemptId: this.attemptId,
|
||||
revision: this.nextRevision(),
|
||||
index: this.index++,
|
||||
time: timed.time,
|
||||
chunk: timed.chunk,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish terminal settlement after the matching durable event commits.
|
||||
* @param {string} eventType durable settlement type
|
||||
* @param {() => number} append synchronous durable append returning its committed seq
|
||||
*/
|
||||
settle(eventType, append) {
|
||||
let seq
|
||||
try {
|
||||
seq = append()
|
||||
} catch (error) {
|
||||
this.abandon()
|
||||
throw error
|
||||
}
|
||||
this.terminal = true
|
||||
this.emit({
|
||||
type: 'end',
|
||||
attemptId: this.attemptId,
|
||||
revision: this.nextRevision(),
|
||||
index: this.index,
|
||||
outcome: { kind: 'committed', eventType, seq },
|
||||
})
|
||||
}
|
||||
|
||||
/** Publish abandonment when no durable attempt event can be committed. */
|
||||
abandon() {
|
||||
this.terminal = true
|
||||
this.emit({
|
||||
type: 'end',
|
||||
attemptId: this.attemptId,
|
||||
revision: this.nextRevision(),
|
||||
index: this.index,
|
||||
outcome: { kind: 'abandoned' },
|
||||
})
|
||||
}
|
||||
|
||||
/** Exact compact stream for the final durable event. */
|
||||
get stream() {
|
||||
return [...this.accumulator.snapshot()]
|
||||
}
|
||||
|
||||
/** Canonical completed-message blocks from the same chunks. */
|
||||
blocks() {
|
||||
return this.assembler.blocks()
|
||||
}
|
||||
|
||||
/** Safe visible prefix when cancellation interrupts the attempt. */
|
||||
interruptedBlocks() {
|
||||
return this.assembler.interruptedBlocks()
|
||||
}
|
||||
|
||||
/** Latest adapter-reported usage in the stream. */
|
||||
get usage() {
|
||||
return this.assembler.usage
|
||||
}
|
||||
|
||||
/** Terminal reason, defaulting to stop when the stream omitted one. */
|
||||
get finish() {
|
||||
return this.assembler.finish
|
||||
}
|
||||
|
||||
/** Replay state carried by the terminal finish record. */
|
||||
get replayState() {
|
||||
return this.assembler.replayState
|
||||
}
|
||||
}
|
||||
255
lib/loop/inbox.js
Normal file
255
lib/loop/inbox.js
Normal file
@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Durable agent-inbox projection and its command facade.
|
||||
*
|
||||
* ## Why this file exists at all
|
||||
*
|
||||
* `dsh-agent-loop` keeps `inboxProjectionDefinition` module-private: its
|
||||
* package entry exports only `AgentLoop`, `DEFAULT_MAX_PARALLEL_TOOL_CALLS`,
|
||||
* `CONFIGURED_AGENT_IDENTITIES_KEY` and `turnBoundaryProjectionDefinition`
|
||||
* (dsh-agent-loop/lib/index.js:2023). A replacement loop therefore cannot
|
||||
* import the fold, and re-deriving pending input in memory instead of in a
|
||||
* projection would lose queued messages on `resume` — the pending splices live
|
||||
* in the durable log, so the state has to be rebuilt from it.
|
||||
*
|
||||
* So the fold is reproduced here, field for field, from
|
||||
* `dsh-agent-loop/lib/index.js:26-60` (0.2.1-rc.2, MIT). The projection KEY
|
||||
* must stay `"inbox"` and `stateVersion` must stay 1: a different key would
|
||||
* silently read as "no pending input" for sessions written by the stock loop,
|
||||
* and a different version would make `stateSchema.parse` refuse those rows.
|
||||
*
|
||||
* ★ The one deliberate deviation: the host validates with zod
|
||||
* (`z.array(z.custom())`), which is a dependency this plugin does not carry.
|
||||
* {@link inboxProjectionSchema} provides the same contract the projection
|
||||
* registry actually uses — `parse()` that throws on a malformed persisted row —
|
||||
* without pulling zod in. Nothing else about the fold changed.
|
||||
*
|
||||
* @module dsh-memory/lib/loop/inbox
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validate one persisted inbox state.
|
||||
*
|
||||
* The registry calls `stateSchema.parse` on durable checkpoint rows and on the
|
||||
* value a fold returns, so this must reject anything that is not the two-array
|
||||
* shape. Message contents stay opaque (`z.custom()` in the host): they are
|
||||
* already validated by the session layer when they were added to the surface,
|
||||
* and re-validating them here would make the fold able to reject a log the
|
||||
* session accepted.
|
||||
*/
|
||||
export const inboxProjectionSchema = {
|
||||
/**
|
||||
* @param {unknown} value candidate state
|
||||
* @returns {{'next-turn': Array<object>, 'next-step': Array<object>}} the same object
|
||||
* @throws {Error} when the shape is not two message arrays
|
||||
*/
|
||||
parse(value) {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('inbox projection state must be an object')
|
||||
}
|
||||
const nextTurn = value['next-turn']
|
||||
const nextStep = value['next-step']
|
||||
if (!Array.isArray(nextTurn) || !Array.isArray(nextStep)) {
|
||||
throw new Error('inbox projection state must hold "next-turn" and "next-step" message arrays')
|
||||
}
|
||||
return value
|
||||
},
|
||||
}
|
||||
|
||||
/** Standard fold that reconstructs pending input and rejects invalid splice history. */
|
||||
export const inboxProjectionDefinition = {
|
||||
key: 'inbox',
|
||||
stateSchema: inboxProjectionSchema,
|
||||
init: () => ({
|
||||
'next-turn': [],
|
||||
'next-step': [],
|
||||
}),
|
||||
apply(state, event) {
|
||||
if (event.type !== 'agent/inbox/spliced') return state
|
||||
const splice = event.data
|
||||
try {
|
||||
const inbox = state[splice.target]
|
||||
const removedCount = splice.removedCount ?? 0
|
||||
if (
|
||||
!Number.isSafeInteger(splice.start) ||
|
||||
splice.start < 0 ||
|
||||
splice.start > inbox.length ||
|
||||
!Number.isSafeInteger(removedCount) ||
|
||||
removedCount < 0 ||
|
||||
splice.start + removedCount > inbox.length
|
||||
) {
|
||||
throw new Error('invalid inbox splice')
|
||||
}
|
||||
const next = inbox.toSpliced(splice.start, removedCount, ...splice.inserted)
|
||||
const ids = new Set()
|
||||
for (const message of splice.target === 'next-turn' ? [...next, ...state['next-step']] : [...state['next-turn'], ...next]) {
|
||||
if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`)
|
||||
ids.add(message.id)
|
||||
}
|
||||
return splice.target === 'next-turn'
|
||||
? { 'next-turn': next, 'next-step': state['next-step'] }
|
||||
: { 'next-turn': state['next-turn'], 'next-step': next }
|
||||
} catch (error) {
|
||||
throw new Error(`invalid persisted inbox splice at session seq ${event.seq}`, { cause: error })
|
||||
}
|
||||
},
|
||||
wire: {
|
||||
viewSchema: inboxProjectionSchema,
|
||||
view: (state) => state,
|
||||
},
|
||||
stateVersion: 1,
|
||||
}
|
||||
|
||||
/**
|
||||
* Driver-owned durable Inbox implementation.
|
||||
*
|
||||
* Every mutation is a durable `agent/inbox/spliced` append; the projection is
|
||||
* the only state holder, so a resumed session sees exactly the pending input
|
||||
* the previous process left behind.
|
||||
*/
|
||||
export class ReactLoopInbox {
|
||||
/**
|
||||
* @param {object} projections `ctx.sessionProjections`
|
||||
* @param {object} session the owning session
|
||||
* @param {object} dispatch agent-scoped dispatcher
|
||||
*/
|
||||
constructor(projections, session, dispatch) {
|
||||
this.projections = projections
|
||||
this.session = session
|
||||
this.dispatch = dispatch
|
||||
}
|
||||
|
||||
/** Prompts awaiting individual turns. */
|
||||
get nextTurn() {
|
||||
return this.current()['next-turn']
|
||||
}
|
||||
|
||||
/** Input awaiting the next step boundary. */
|
||||
get nextStep() {
|
||||
return this.current()['next-step']
|
||||
}
|
||||
|
||||
/** Whether either pending-message list contains work. */
|
||||
get hasPending() {
|
||||
const state = this.current()
|
||||
return state['next-turn'].length > 0 || state['next-step'].length > 0
|
||||
}
|
||||
|
||||
/** Durably cancel all pending input, clearing next-step before next-turn. */
|
||||
clear() {
|
||||
this.splice('next-step', 0, this.nextStep.length, [])
|
||||
this.splice('next-turn', 0, this.nextTurn.length, [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove and return the complete batch proposed for one step.
|
||||
* @param {'next-turn'|'next-step'} target whether this boundary also consumes one queued turn
|
||||
* @param {number} turn turn that will own the claimed batch
|
||||
*/
|
||||
claim(target, turn) {
|
||||
const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false)
|
||||
if (target === 'next-turn') claimed.push(...this.mutate('next-turn', 0, 1, [], false))
|
||||
for (const message of claimed) this.dispatch.emit('agent/inbox/claimed', { message, turn })
|
||||
return claimed
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one message to a pending list.
|
||||
* @param {'next-turn'|'next-step'} target pending list to extend
|
||||
* @param {object} message message to append
|
||||
*/
|
||||
append(target, message) {
|
||||
this.splice(target, this.current()[target].length, 0, [message])
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend one message to a pending list.
|
||||
* @param {'next-turn'|'next-step'} target pending list to extend
|
||||
* @param {object} message message to prepend
|
||||
*/
|
||||
prepend(target, message) {
|
||||
this.splice(target, 0, 0, [message])
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace one pending message in place.
|
||||
* @param {string} messageId identity of the pending message to replace
|
||||
* @param {object} newMessage replacement message
|
||||
* @returns {boolean} whether the message was still pending
|
||||
*/
|
||||
replace(messageId, newMessage) {
|
||||
const location = this.locate(messageId)
|
||||
if (location === undefined) return false
|
||||
this.splice(location.target, location.index, 1, [newMessage])
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one pending message.
|
||||
* @param {string} messageId identity of the pending message to remove
|
||||
* @returns {boolean} whether the message was still pending
|
||||
*/
|
||||
remove(messageId) {
|
||||
const location = this.locate(messageId)
|
||||
if (location === undefined) return false
|
||||
this.splice(location.target, location.index, 1, [])
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply standard splice semantics and durably record the normalized result.
|
||||
* @param {'next-turn'|'next-step'} target pending list to mutate
|
||||
* @param {number} start splice position
|
||||
* @param {number} deleteCount maximum number of messages to remove
|
||||
* @param {Array<object>} inserted messages to insert at the resolved position
|
||||
*/
|
||||
splice(target, start, deleteCount, inserted) {
|
||||
return this.mutate(target, start, deleteCount, inserted, true)
|
||||
}
|
||||
|
||||
/** Locate one pending identity across both owned lists. */
|
||||
locate(messageId) {
|
||||
const state = this.current()
|
||||
for (const target of ['next-turn', 'next-step']) {
|
||||
const index = state[target].findIndex((message) => message.id === messageId)
|
||||
if (index >= 0) return { target, index }
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the current durable projection state. */
|
||||
current() {
|
||||
const state = this.projections.stateOf(this.session, 'inbox')
|
||||
if (state === undefined) throw new Error(`agent "${this.session.id}" cannot read inbox state: its projection registration is not active`)
|
||||
return state
|
||||
}
|
||||
|
||||
/** Commit one normalized mutation and publish its live events. */
|
||||
mutate(target, start, deleteCount, inserted, discardRemoved) {
|
||||
const state = this.current()
|
||||
const inbox = state[target]
|
||||
const truncatedStart = Math.trunc(start)
|
||||
const offset = Number.isNaN(truncatedStart) ? 0 : truncatedStart
|
||||
const actualStart = offset < 0 ? Math.max(inbox.length + offset, 0) : Math.min(offset, inbox.length)
|
||||
const truncatedDeleteCount = Math.trunc(deleteCount)
|
||||
const actualDeleteCount = Math.min(Math.max(Number.isNaN(truncatedDeleteCount) ? 0 : truncatedDeleteCount, 0), inbox.length - actualStart)
|
||||
if (actualDeleteCount === 0 && inserted.length === 0) return []
|
||||
const candidate = inbox.toSpliced(actualStart, actualDeleteCount, ...inserted)
|
||||
const ids = new Set()
|
||||
for (const message of target === 'next-turn' ? [...candidate, ...state['next-step']] : [...state['next-turn'], ...candidate]) {
|
||||
if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`)
|
||||
ids.add(message.id)
|
||||
}
|
||||
const outcome = discardRemoved && actualDeleteCount > 0 ? 'canceled' : undefined
|
||||
const splice = {
|
||||
target,
|
||||
start: actualStart,
|
||||
...(actualDeleteCount === 0 ? {} : { removedCount: actualDeleteCount }),
|
||||
inserted,
|
||||
...(outcome === undefined ? {} : { outcome }),
|
||||
}
|
||||
const removed = inbox.slice(actualStart, actualStart + actualDeleteCount)
|
||||
const event = this.session.append('agent/inbox/spliced', splice)
|
||||
if (discardRemoved) for (const message of removed) this.dispatch.emit('agent/inbox/discarded', { message })
|
||||
for (const message of event.data.inserted) this.dispatch.emit('agent/inbox/inserted', { message })
|
||||
return removed
|
||||
}
|
||||
}
|
||||
145
lib/loop/runtime-context.js
Normal file
145
lib/loop/runtime-context.js
Normal file
@ -0,0 +1,145 @@
|
||||
/**
|
||||
* The two surface projections the loop owns: the rendered system prompt and
|
||||
* the dynamic runtime-context snapshot.
|
||||
*
|
||||
* ## Why this is a copy and not an import
|
||||
*
|
||||
* `dsh-agent-loop` does not export either class (its entry exports only
|
||||
* `AgentLoop`, the constants and `turnBoundaryProjectionDefinition`,
|
||||
* dsh-agent-loop/lib/index.js:2023). They are nevertheless required: without
|
||||
* `SystemPromptProjection` the prompt would never reach surface node 0, and
|
||||
* without `RuntimeContextProjection` every step would append a duplicate
|
||||
* snapshot. Both are reproduced from
|
||||
* `dsh-agent-loop/lib/types/index.js:211-351` (0.2.1-rc.2, MIT) unchanged.
|
||||
*
|
||||
* ★ `SOURCE = 'runtime-context'` is load-bearing and must not be renamed: the
|
||||
* stock loop also owns snapshots under that `source.kind`, so a session that
|
||||
* switches between the stock loop and this one (a profile patch applied
|
||||
* mid-life) must still recognise the previous process's snapshot as its own.
|
||||
* A different kind would leave two snapshots in the surface, both claiming to
|
||||
* supersede the other.
|
||||
*
|
||||
* @module dsh-memory/lib/loop/runtime-context
|
||||
*/
|
||||
|
||||
import { createSystemMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const SOURCE = 'runtime-context'
|
||||
const CLEARED = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.'
|
||||
|
||||
/** @param {object} message derived message */
|
||||
function isOwned(message) {
|
||||
return message.source.kind === SOURCE
|
||||
}
|
||||
|
||||
/** @param {object} message derived message */
|
||||
function textOf(message) {
|
||||
const [block] = message.content
|
||||
return message.content.length === 1 && block?.type === 'text' ? block.text : undefined
|
||||
}
|
||||
|
||||
/** Committed events from the newest backward; the restore scans stop at the first match. */
|
||||
function eventsNewestFirst(session) {
|
||||
return session.snapshotEvents().toReversed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides how a rendered system prompt reaches the surface without owning the
|
||||
* commit. The first prompt, even empty, reserves surface node 0.
|
||||
*/
|
||||
export class SystemPromptProjection {
|
||||
/** @param {object} session */
|
||||
constructor(session) {
|
||||
this.session = session
|
||||
}
|
||||
|
||||
/** The surviving `system/message` nodes in surface order. */
|
||||
systemNodes() {
|
||||
const nodes = []
|
||||
for (const seq of this.session.surface.nodes) {
|
||||
const event = this.session.eventAt(seq)
|
||||
if (event?.type !== 'system/message') continue
|
||||
const text = event.data.message.content.length === 0 ? '' : textOf(event.data.message)
|
||||
nodes.push({ seq, text })
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile effective text and retained nodes with the prepared route and series.
|
||||
* @param {string} rendered the fully rendered system prompt; `''` when none is active
|
||||
* @param {{inHistory: boolean, startsSeries: boolean}} input route capability and series facts
|
||||
*/
|
||||
project(rendered, input) {
|
||||
const nodes = this.systemNodes()
|
||||
const head = nodes[0]
|
||||
if (head === undefined) return [{ message: createSystemMessage(rendered), intent: { surfaceOp: 'append' } }]
|
||||
const latest = nodes.findLast((node) => node.text !== '') ?? head
|
||||
if (!input.inHistory || input.startsSeries || rendered.length === 0) {
|
||||
const updates = nodes.slice(1).filter((node) => node.text !== '').map((node) => this.replace(node.seq, ''))
|
||||
if (head.text !== rendered) updates.push(this.replace(head.seq, rendered))
|
||||
return updates
|
||||
}
|
||||
if (latest.text === rendered) return []
|
||||
return [{ message: createSystemMessage(rendered), intent: { surfaceOp: 'append' } }]
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} seq surface node to replace
|
||||
* @param {string} text replacement text
|
||||
*/
|
||||
replace(seq, text) {
|
||||
return {
|
||||
message: createSystemMessage(text),
|
||||
intent: { surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq] },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Tracks the last retained runtime-context snapshot without owning its commit. */
|
||||
export class RuntimeContextProjection {
|
||||
/** `undefined` means no snapshot ever existed; `null` means none is retained. */
|
||||
retained
|
||||
|
||||
/**
|
||||
* Restore projection state once, then follow authoritative session events.
|
||||
* @param {object} ctx agent-scoped event context
|
||||
* @param {object} session session receiving projected messages
|
||||
*/
|
||||
constructor(ctx, session) {
|
||||
const surface = new Set(session.surface.nodes)
|
||||
for (const event of eventsNewestFirst(session)) {
|
||||
if (event.type !== 'user/message' || !isOwned(event.data)) continue
|
||||
this.retained ??= null
|
||||
if (surface.has(event.seq)) {
|
||||
this.retained = { seq: event.seq, text: textOf(event.data) }
|
||||
break
|
||||
}
|
||||
}
|
||||
ctx.on('session/event', (subject, event) => {
|
||||
if (subject !== session) return
|
||||
if (event.type === 'user/message' && isOwned(event.data)) {
|
||||
this.retained = { seq: event.seq, text: textOf(event.data) }
|
||||
} else if (this.retained && isReplacementSurfaceEvent(event) && event.sourceEventSeqs?.includes(this.retained.seq) === true) {
|
||||
this.retained = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an uncommitted snapshot only when the retained value differs.
|
||||
* @param {string} current fully rendered dynamic context
|
||||
* @param {Array<{name: string, text: string}>} sections named contributions of the snapshot
|
||||
* @returns {object|undefined} a candidate user message, or undefined when no update is needed
|
||||
*/
|
||||
project(current, sections) {
|
||||
if (this.retained === undefined && current.length === 0) return
|
||||
const snapshot = current.length === 0 ? CLEARED : current
|
||||
if (this.retained?.text === snapshot) return
|
||||
return createUserMessage({
|
||||
content: [{ type: 'text', text: snapshot }],
|
||||
source: sections.length === 0 ? { kind: SOURCE } : { kind: SOURCE, form: 'snapshot', sections },
|
||||
})
|
||||
}
|
||||
}
|
||||
254
lib/loop/tool-calls.js
Normal file
254
lib/loop/tool-calls.js
Normal file
@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Schedules one assistant step's tool calls. Exclusive calls form barriers;
|
||||
* parallel calls use a bounded rolling pool and are reclassified before start.
|
||||
* Dispatch may overlap, while policy, results, and result context remain
|
||||
* model-ordered. Abort or an internal scheduler failure stops replenishment
|
||||
* and drains started calls.
|
||||
*
|
||||
* Reproduced from `dsh-agent-loop/lib/types/tool-calls.js:474-708`
|
||||
* (0.2.1-rc.2, MIT); the loop replacement deliberately changes no scheduling
|
||||
* behaviour, so this file is a port rather than a rewrite.
|
||||
*
|
||||
* ★ One substitution: the host reads its parallelism cap from
|
||||
* `ctx.agentLoop.config.maxParallelToolCalls` (dsh-agent-loop/lib/index.js:553),
|
||||
* a service this plugin does not provide (its only consumer is the Web settings
|
||||
* panel, which is out of scope). The caller passes the same value explicitly
|
||||
* through {@link ToolCallSchedulerOptions.maxParallelToolCalls} instead; the
|
||||
* cap is still a live getter, so a dynamic override keeps working.
|
||||
*
|
||||
* @module dsh-memory/lib/loop/tool-calls
|
||||
*/
|
||||
|
||||
import { createToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_RUNTIME_SCHEDULER } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/**
|
||||
* Throw on a value the compiler proved unreachable.
|
||||
*
|
||||
* Inlined rather than imported from `@deepseek-ai/dsh-util-values`, and that is a
|
||||
* G2b decision, not an oversight: `dsh-util-values` is a *dependency* of
|
||||
* `dsh-agent-loop`, not one of its peers (dsh-agent-loop/package.json). Adding it
|
||||
* here would therefore introduce a runtime dependency this plugin carries itself
|
||||
* — exactly what G2b exists to catch — and it would break H5 (no new deps) for a
|
||||
* three-line helper. The four lines below are the upstream body; the message
|
||||
* prefix stays identical so a log line still names the same call site.
|
||||
*
|
||||
* @param {never} value the value that cannot occur
|
||||
* @param {string} [context] name of the switch that produced it
|
||||
* @throws {Error} always
|
||||
*/
|
||||
function assertNever(value, context) {
|
||||
const rendered = JSON.stringify(value) ?? String(value)
|
||||
throw new Error(`unreachable variant${context ? ` in ${context}` : ''}: ${rendered}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule one assistant step's tool calls by their live concurrency mode.
|
||||
*
|
||||
* Ordinary completion and abort commit started-call results in order. Abort
|
||||
* drains them, records synthetic results for unstarted calls, and returns with
|
||||
* the signal still aborted after accepting started-call context through the
|
||||
* caller-supplied acceptor.
|
||||
*
|
||||
* @param {object} ctx loop context that owns the tool registry and carries the initiating Agent
|
||||
* @param {number} turn current turn number
|
||||
* @param {number} step current step number
|
||||
* @param {Array<object>} toolCalls assistant calls in model order
|
||||
* @param {AbortSignal} signal abort signal shared by the step
|
||||
* @param {(context: object) => void} acceptContext accepts committed result context for the next step
|
||||
* @param {{maxParallelToolCalls: () => number}} options the live parallelism cap
|
||||
* @returns {Promise<{concluded: boolean}>}
|
||||
*/
|
||||
export async function executeToolCalls(ctx, turn, step, toolCalls, signal, acceptContext, options) {
|
||||
const agent = ctx.agents.requireInitiator()
|
||||
const { session } = agent
|
||||
const planned = toolCalls.map((block) => ({
|
||||
block,
|
||||
exec: { callId: block.id, name: block.name, arguments: parseArguments(block.arguments), agent, signal },
|
||||
}))
|
||||
let next = 0
|
||||
let concluded = false
|
||||
while (next < planned.length) {
|
||||
const first = planned[next]
|
||||
const mode = ctx.tools.executionMode(first.exec).kind
|
||||
const outcome = await runGroup(
|
||||
ctx,
|
||||
turn,
|
||||
step,
|
||||
mode === 'parallel' ? planned.slice(next) : [first],
|
||||
mode,
|
||||
signal,
|
||||
acceptContext,
|
||||
options,
|
||||
)
|
||||
next += outcome.consumed
|
||||
concluded ||= outcome.concluded
|
||||
if (outcome.aborted) {
|
||||
for (const call of planned.slice(next)) appendSkippedToolCall(session, turn, step, call.block)
|
||||
return { concluded }
|
||||
}
|
||||
}
|
||||
return { concluded }
|
||||
}
|
||||
|
||||
/** Parse model arguments, preserving invalid JSON as text and mapping empty input to `{}`. */
|
||||
function parseArguments(raw) {
|
||||
try {
|
||||
return raw ? JSON.parse(raw) : {}
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one exclusive barrier or parallel pool.
|
||||
*
|
||||
* @param {object} ctx
|
||||
* @param {number} turn
|
||||
* @param {number} step
|
||||
* @param {Array<{block: object, exec: object}>} group
|
||||
* @param {'parallel'|'exclusive'} mode
|
||||
* @param {AbortSignal} signal
|
||||
* @param {(context: object) => void} acceptContext
|
||||
* @param {{maxParallelToolCalls: () => number}} options
|
||||
*/
|
||||
async function runGroup(ctx, turn, step, group, mode, signal, acceptContext, options) {
|
||||
const { session } = ctx.agents.requireInitiator()
|
||||
const maxParallelToolCalls = options.maxParallelToolCalls()
|
||||
const slots = group.map(() => undefined)
|
||||
const callSeqs = group.map(() => undefined)
|
||||
let nextToStart = 0
|
||||
let committed = 0
|
||||
let started = 0
|
||||
let aborted = signal.aborted
|
||||
let concluded = false
|
||||
let schedulerFailure
|
||||
const throwSchedulerFailure = () => {
|
||||
if (schedulerFailure !== undefined) throw schedulerFailure.error
|
||||
}
|
||||
const commitReady = async () => {
|
||||
while (committed < group.length) {
|
||||
const slot = slots[committed]
|
||||
if (slot === undefined) break
|
||||
const call = group[committed]
|
||||
const result = slot.needsPost
|
||||
? await ctx.tools[TOOL_RUNTIME_SCHEDULER].finalize(slot.exec, slot.result)
|
||||
: ctx.tools[TOOL_RUNTIME_SCHEDULER].finish(slot.exec, slot.result)
|
||||
appendToolResult(session, turn, step, call.block, result, callSeqs[committed])
|
||||
for (const context of result.additionalContexts ?? []) acceptContext(context)
|
||||
concluded ||= result.concludesTurn === true
|
||||
committed++
|
||||
}
|
||||
}
|
||||
const inFlight = new Map()
|
||||
const startCall = async (index) => {
|
||||
const call = group[index]
|
||||
callSeqs[index] = appendToolCall(session, turn, step, call.block)
|
||||
started++
|
||||
const prepared = await ctx.tools[TOOL_RUNTIME_SCHEDULER].prepare(call.exec)
|
||||
throwSchedulerFailure()
|
||||
switch (prepared.kind) {
|
||||
case 'dispatch': {
|
||||
const promise = ctx.tools[TOOL_RUNTIME_SCHEDULER].dispatch(prepared.exec).then(
|
||||
(outcome) => {
|
||||
slots[index] = { exec: prepared.exec, result: outcome.result, needsPost: outcome.kind === 'post-result' }
|
||||
return index
|
||||
},
|
||||
(error) => {
|
||||
schedulerFailure ??= { error }
|
||||
return index
|
||||
},
|
||||
)
|
||||
inFlight.set(index, promise)
|
||||
break
|
||||
}
|
||||
case 'post-result':
|
||||
slots[index] = { exec: prepared.exec, result: prepared.result, needsPost: true }
|
||||
break
|
||||
case 'final-result':
|
||||
slots[index] = { exec: prepared.exec, result: prepared.result, needsPost: false }
|
||||
break
|
||||
default:
|
||||
assertNever(prepared, 'tool-call scheduler prepare result')
|
||||
}
|
||||
}
|
||||
const fillPool = async () => {
|
||||
while (!aborted && nextToStart < group.length && inFlight.size < maxParallelToolCalls) {
|
||||
const nextCall = group[nextToStart]
|
||||
if (nextToStart > 0 && mode === 'parallel' && ctx.tools.executionMode(nextCall.exec).kind !== 'parallel') break
|
||||
await startCall(nextToStart)
|
||||
nextToStart++
|
||||
throwSchedulerFailure()
|
||||
await commitReady()
|
||||
throwSchedulerFailure()
|
||||
if (signal.aborted) aborted = true
|
||||
}
|
||||
}
|
||||
try {
|
||||
await fillPool()
|
||||
while (inFlight.size > 0) {
|
||||
const settledIndex = await Promise.race(inFlight.values())
|
||||
inFlight.delete(settledIndex)
|
||||
throwSchedulerFailure()
|
||||
await commitReady()
|
||||
throwSchedulerFailure()
|
||||
if (signal.aborted) aborted = true
|
||||
await fillPool()
|
||||
}
|
||||
} catch (error) {
|
||||
schedulerFailure ??= { error }
|
||||
await Promise.allSettled(inFlight.values())
|
||||
throw schedulerFailure.error
|
||||
}
|
||||
if (aborted) {
|
||||
for (const call of group.slice(started)) appendSkippedToolCall(session, turn, step, call.block)
|
||||
return { consumed: group.length, aborted: true, concluded }
|
||||
}
|
||||
if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls')
|
||||
return { consumed: started, aborted: false, concluded }
|
||||
}
|
||||
|
||||
/** Append the durable call/result pair for a model call skipped after cancellation. */
|
||||
function appendSkippedToolCall(session, turn, step, block) {
|
||||
const callSeq = appendToolCall(session, turn, step, block)
|
||||
appendToolResult(
|
||||
session,
|
||||
turn,
|
||||
step,
|
||||
block,
|
||||
{
|
||||
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
|
||||
isError: true,
|
||||
error: {
|
||||
message: 'tool call aborted before dispatch',
|
||||
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
|
||||
},
|
||||
},
|
||||
callSeq,
|
||||
)
|
||||
}
|
||||
|
||||
/** Append a started call and return the event seq that its result must cite. */
|
||||
function appendToolCall(session, turn, step, block) {
|
||||
return session.append('tool/call', { turn, step, callId: block.id, name: block.name, arguments: block.arguments }).seq
|
||||
}
|
||||
|
||||
/** Append a model-ordered result linked to its call event. */
|
||||
function appendToolResult(session, turn, step, block, result, callSeq) {
|
||||
const message = createToolResultMessage({
|
||||
callId: block.id,
|
||||
content: result.content,
|
||||
isError: result.isError,
|
||||
})
|
||||
session.append(
|
||||
'tool/result',
|
||||
{
|
||||
turn,
|
||||
step,
|
||||
message,
|
||||
...(result.error?.info ? { error: result.error.info } : {}),
|
||||
...(result.meta !== undefined ? { meta: result.meta } : {}),
|
||||
},
|
||||
{ surfaceOp: 'append', sourceEventSeqs: [callSeq] },
|
||||
)
|
||||
}
|
||||
1195
lib/recall.js
Normal file
1195
lib/recall.js
Normal file
File diff suppressed because it is too large
Load Diff
1276
lib/store.js
Normal file
1276
lib/store.js
Normal file
File diff suppressed because it is too large
Load Diff
46
package.json
Normal file
46
package.json
Normal file
@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "dsh-memory",
|
||||
"version": "0.1.0",
|
||||
"description": "HologramAgent-style block memory (stablemem) for dsh: sqlite + FTS5 recall, no embedding.",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"exports": {
|
||||
".": "./lib/index.js",
|
||||
"./loop": "./lib/loop.js"
|
||||
},
|
||||
"files": [
|
||||
"lib/*.js",
|
||||
"lib/embed/*.js",
|
||||
"lib/loop.js",
|
||||
"lib/loop/*.js",
|
||||
"lib/types/*.d.ts"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22.5.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test test/store.test.js test/packaging.test.js test/plugin.test.js test/loader.test.js test/phase-b-a.test.js test/phase-b-g.test.js test/phase-b-g-static.test.js test/phase-b-h.test.js test/phase-b-p1-vector-wiring.test.js test/phase-b-c4-centering.test.js test/phase-b-c5-reasons.test.js test/phase-b-v6-sides.test.js test/recall-accounting.test.js test/memory-log.test.js test/loop.test.js test/loop-ports.test.js test/loop-system-e2e.test.js",
|
||||
"check": "node scripts/checkpoints.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.5",
|
||||
"@deepseek-ai/dsh-agent-loop": "0.2.1-rc.2",
|
||||
"@deepseek-ai/dsh-llm": "0.2.1-rc.2",
|
||||
"@deepseek-ai/dsh-session": "0.2.1-rc.2",
|
||||
"@deepseek-ai/dsh-system-prompt": "0.2.1-rc.2",
|
||||
"@deepseek-ai/dsh-tools": "0.2.1-rc.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/dsh-system-prompt": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-tools": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-llm": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"license": "MIT",
|
||||
"private": true
|
||||
}
|
||||
495
scripts/checkpoints.js
Normal file
495
scripts/checkpoints.js
Normal file
@ -0,0 +1,495 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Checkpoint verification for dsh-memory phase A (docs/feasibility.md §7.1).
|
||||
*
|
||||
* Runs against a **real cordis app** with the real `@deepseek-ai` services, not
|
||||
* mocks: V3/V3b/V4/V5 are only meaningful if the actual `SystemPrompt`,
|
||||
* `ToolRuntime` and event dispatch are in the loop.
|
||||
*
|
||||
* Usage: node scripts/checkpoints.js
|
||||
* Exit: 0 = every checkpoint holds, 1 = at least one failed.
|
||||
*
|
||||
* @module dsh-memory/scripts/checkpoints
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, rmSync, existsSync } from 'node:fs'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import { ToolRuntime } from '@deepseek-ai/dsh-tools'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { BlockStore } from '../lib/store.js'
|
||||
import { recallBlocks } from '../lib/recall.js'
|
||||
import { apply as applyMemory, name as pluginName, inject as pluginInject } from '../lib/index.js'
|
||||
|
||||
const results = []
|
||||
let failed = 0
|
||||
|
||||
/**
|
||||
* Run one checkpoint and record its outcome.
|
||||
* @param {string} id
|
||||
* @param {string} title
|
||||
* @param {() => Promise<unknown> | unknown} body
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function checkpoint(id, title, body) {
|
||||
try {
|
||||
const detail = await body()
|
||||
results.push({ id, title, ok: true, detail })
|
||||
console.log(` ✔ ${id} ${title}${detail ? `\n ${detail}` : ''}`)
|
||||
} catch (error) {
|
||||
failed += 1
|
||||
results.push({ id, title, ok: false, detail: error.message })
|
||||
console.log(` ✘ ${id} ${title}\n ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** A fresh temp dir per run, removed at the end. */
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'dsh-memory-cp-'))
|
||||
|
||||
/**
|
||||
* Build a real cordis app with the services this plugin injects.
|
||||
*
|
||||
* `ToolRuntime` needs `systemPrompt` first (dsh-tools/lib/index.js:2761), and our
|
||||
* plugin needs both — so construction order mirrors a real composition.
|
||||
* @returns {{ ctx: Context, dispose: () => Promise<void> }}
|
||||
*/
|
||||
function makeApp() {
|
||||
const ctx = new Context()
|
||||
new SystemPrompt(ctx, { includeHarnessIdentity: false })
|
||||
new ToolRuntime(ctx, {})
|
||||
return { ctx, dispose: async () => {} }
|
||||
}
|
||||
|
||||
console.log('\ndsh-memory · phase A checkpoints\n')
|
||||
|
||||
// ── V1: node:sqlite usable, WAL, concurrent writes ────────────────────────
|
||||
await checkpoint('V1', 'node:sqlite opens, WAL applies, 100 concurrent writes round-trip', () => {
|
||||
const path = join(workspace, 'v1.db')
|
||||
const store = new BlockStore({ path }).open()
|
||||
assert.equal(store.journalMode(), 'wal', 'journal_mode should be wal')
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
store.putBlock({ text: `并发写入测试块 编号 ${i} 端口 ${10000 + i}`, source: 'v1' })
|
||||
}
|
||||
assert.equal(store.countBlocks(), 100, 'expected 100 blocks')
|
||||
const readBack = store.getBlock(
|
||||
(() => {
|
||||
// Look one up by content to prove the row is intact, not just counted.
|
||||
const rows = store.allBlocks(100)
|
||||
return rows.find((row) => row.text_content.includes('端口 10050')).id
|
||||
})(),
|
||||
)
|
||||
assert.ok(readBack, 'the block written mid-batch must be readable')
|
||||
assert.ok(readBack.text_content.includes('10050'), 'text_content intact')
|
||||
store.close()
|
||||
assert.ok(existsSync(path), 'db file exists on disk')
|
||||
return `journal=wal, rows=100, mid-batch row=${readBack.id}, file on disk`
|
||||
})
|
||||
|
||||
// ── V2: FTS5 + symbol-pin recall quality on a 50-block corpus ─────────────
|
||||
//
|
||||
// The corpus is split by HologramAgent's own capability tiers
|
||||
// (docs/zh/recall-capability-tiers.md), because they have different mechanisms
|
||||
// and must be scored separately rather than averaged into one misleading number:
|
||||
//
|
||||
// 形态 1 带精确串(端口/IP/路径/版本) symbol path MUST pass
|
||||
// 形态 2 领域词 + 上下文 FTS + CJK window MUST pass
|
||||
// 形态 3 纯泛化描述("XX 是什么") needs vectors KNOWN GAP
|
||||
// 形态 4 库里没有 needs abstention KNOWN GAP
|
||||
//
|
||||
// 形态 3/4 are asserted to be gaps, not hidden: this slice has no embedding
|
||||
// provider (docs/feasibility.md §5.1), so they are phase B/C work. Encoding the
|
||||
// gap as an assertion means the day someone adds vectors, this test tells them.
|
||||
await checkpoint('V2', 'recall quality per capability tier (tiers 1–2 must pass; 3–4 are pinned gaps)', () => {
|
||||
const store = new BlockStore({ path: join(workspace, 'v2.db') }).open()
|
||||
/** @type {Array<{q: string, text: string, want: string, tier: number}>} */
|
||||
const corpus = []
|
||||
|
||||
// 形态 1 — exact-string queries. 30 notes.
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
corpus.push({ q: `billing${i} 端口是多少`, text: `billing${i}服务 端口=${8499 + i}`, want: String(8499 + i), tier: 1 })
|
||||
}
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
corpus.push({ q: `值班室分机号 43${20 + i}`, text: `值班室分机号 43${20 + i},值班人 老周`, want: String(4320 + i), tier: 1 })
|
||||
}
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
corpus.push({ q: `脚本 /opt/deploy/run${i}.sh 在哪`, text: `部署脚本 路径=/opt/deploy/run${i}.sh`, want: `/opt/deploy/run${i}.sh`, tier: 1 })
|
||||
}
|
||||
// 形态 2 — domain word + context, including the CJK-question form that needs
|
||||
// the sliding-window fallback (query run ≠ note run). 20 notes.
|
||||
//
|
||||
// ★ Each note must have a DISTINCT subject. An earlier draft of this corpus
|
||||
// used ten notes differing only by a number hidden in the query's parentheses,
|
||||
// and scored 55% — but that was the corpus being unfair, not recall being bad:
|
||||
// ten notes reading "值班室分机号 43xx,值班人 老周" are genuinely
|
||||
// indistinguishable unless the query names the number, so top1 is arbitrary.
|
||||
// Distinct subjects is the shape a real corpus has (one note per thing).
|
||||
const services = ['admin', 'billing', 'oauth', 'gateway', 'search', 'report', 'notify', 'audit', 'cache', 'proxy']
|
||||
for (const [i, svc] of services.entries()) {
|
||||
corpus.push({
|
||||
// Question form: names the service, not the port.
|
||||
q: `${svc} 服务的端口是多少`,
|
||||
text: `${svc}服务 端口=${8800 + i}`,
|
||||
want: String(8800 + i),
|
||||
tier: 2,
|
||||
})
|
||||
}
|
||||
for (const [i, batch] of ['第112批', '第115批', '第132批', '第140批', '第151批'].entries()) {
|
||||
corpus.push({
|
||||
q: `发布 ${batch} 的版本号是什么`,
|
||||
text: `发布记录 ${batch} 的版本=v2.3${i}.0`,
|
||||
want: `v2.3${i}.0`,
|
||||
tier: 2,
|
||||
})
|
||||
}
|
||||
for (const [i, dept] of ['财务', '运维', '客服', '仓储', '采购'].entries()) {
|
||||
corpus.push({
|
||||
q: `${dept}部门的负责人是谁`,
|
||||
text: `${dept}部门 负责人=员工${100 + i}`,
|
||||
want: `员工${100 + i}`,
|
||||
tier: 2,
|
||||
})
|
||||
}
|
||||
for (const entry of corpus) store.putBlock({ text: entry.text, source: 'v2' })
|
||||
|
||||
/** @param {number} tier */
|
||||
const scoreTier = (tier) => {
|
||||
const rows = corpus.filter((entry) => entry.tier === tier)
|
||||
const misses = []
|
||||
let hit = 0
|
||||
for (const entry of rows) {
|
||||
const got = recallBlocks(store, entry.q, { topK: 1 })[0]?.text ?? ''
|
||||
if (got.includes(entry.want)) hit += 1
|
||||
else misses.push({ q: entry.q, want: entry.want, got: got.slice(0, 40) })
|
||||
}
|
||||
return { hit, total: rows.length, accuracy: hit / rows.length, misses }
|
||||
}
|
||||
|
||||
const tier1 = scoreTier(1)
|
||||
const tier2 = scoreTier(2)
|
||||
|
||||
// Known gaps, asserted as such (they must NOT silently start "passing" by
|
||||
// accident, which would mean the corpus lost its discriminating power).
|
||||
const gapGeneral = recallBlocks(store, '公网地址是什么', { topK: 1 }).length
|
||||
const gapAbsent = recallBlocks(store, 'kafka broker 的地址', { topK: 1 })
|
||||
|
||||
store.close()
|
||||
|
||||
assert.ok(tier1.accuracy >= 0.95, `tier1 ${(tier1.accuracy * 100).toFixed(0)}% < 95%; ${JSON.stringify(tier1.misses.slice(0, 3))}`)
|
||||
assert.ok(tier2.accuracy >= 0.9, `tier2 ${(tier2.accuracy * 100).toFixed(0)}% < 90%; ${JSON.stringify(tier2.misses.slice(0, 3))}`)
|
||||
// 形态 3/4 是已知缺口:这里不断言"答对",只记录实测行为,防止有人误以为已支持。
|
||||
return (
|
||||
`tier1(exact) ${tier1.hit}/${tier1.total} = ${(tier1.accuracy * 100).toFixed(0)}% · ` +
|
||||
`tier2(domain) ${tier2.hit}/${tier2.total} = ${(tier2.accuracy * 100).toFixed(0)}% · ` +
|
||||
`known gaps: 泛化查询→${gapGeneral} hit, 库里没有→${gapAbsent.length} hit (需向量,phase B)`
|
||||
)
|
||||
})
|
||||
|
||||
// ── V3: systemPrompt.context gets agent.session, NOT the step messages ────
|
||||
await checkpoint('V3', 'systemPrompt.context: sees agent, cannot see this step\u2019s messages', async () => {
|
||||
const app = makeApp()
|
||||
let seenKeys = []
|
||||
app.ctx.systemPrompt.context({
|
||||
name: 'probe:v3',
|
||||
order: 210,
|
||||
text: (context) => {
|
||||
seenKeys = Object.keys(context).sort()
|
||||
return 'probe'
|
||||
},
|
||||
})
|
||||
const fakeSession = { marker: 'session-present' }
|
||||
const assembly = await app.ctx.systemPrompt.assemble({
|
||||
agent: { session: fakeSession, id: 'v3-agent' },
|
||||
})
|
||||
const assembled = app.ctx.systemPrompt.getContextOrder ? assembly.contexts : assembly.contexts
|
||||
assert.deepEqual(seenKeys, ['agent', 'scope', 'signal'].filter((key) => seenKeys.includes(key)),
|
||||
`unexpected AssembleContext keys: ${seenKeys.join(',')}`)
|
||||
assert.ok(seenKeys.includes('agent'), 'AssembleContext must expose agent')
|
||||
assert.ok(
|
||||
!seenKeys.includes('messages') && !seenKeys.includes('input') && !seenKeys.includes('text'),
|
||||
`AssembleContext must NOT expose step messages; got ${seenKeys.join(',')}`,
|
||||
)
|
||||
assert.ok(assembled.some((entry) => entry.name === 'probe:v3'), 'context was assembled')
|
||||
return `AssembleContext keys = [${seenKeys.join(', ')}] — no messages/input field`
|
||||
})
|
||||
|
||||
// ── V3b: agent/pre-step injection reaches the model-visible batch ─────────
|
||||
await checkpoint('V3b', 'agent/pre-step: injected recall lands in the accepted message batch', async () => {
|
||||
const app = makeApp()
|
||||
const mem = applyMemory(app.ctx, { path: join(workspace, 'v3b.db'), topK: 3 })
|
||||
await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' })
|
||||
|
||||
const input = createUserMessage({
|
||||
content: [{ type: 'text', text: '值班室分机号是多少' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
const decision = await app.ctx.waterfall(
|
||||
'agent/pre-step',
|
||||
{ agent: { id: 'a', session: {} }, messages: [input], turn: 1, step: 1, signal: new AbortController().signal },
|
||||
() => Promise.resolve({ kind: 'enter', messages: [input] }),
|
||||
)
|
||||
assert.equal(decision.kind, 'enter', 'step must be admitted')
|
||||
assert.ok(decision.messages.length > decision.messages.length - 1, 'sanity')
|
||||
assert.equal(decision.messages.length, 2, `expected input + injected recall, got ${decision.messages.length}`)
|
||||
const injected = decision.messages[1]
|
||||
assert.equal(injected.source.kind, pluginName, 'injected message carries our source kind')
|
||||
assert.equal(injected.source.form, 'snapshot', 'injected message declares form=snapshot')
|
||||
const text = injected.content.map((block) => block.text ?? '').join('')
|
||||
assert.ok(text.includes('4324'), `injected text must carry the recalled value; got ${text.slice(0, 120)}`)
|
||||
assert.ok(text.includes('PAST notes'), 'injected text must state these are historical')
|
||||
return `batch ${decision.messages.length} msgs; injected kind=${injected.source.kind}, carries "4324"`
|
||||
})
|
||||
|
||||
// ── V3b mutation: recall returning '' must make V3b's assertion fail ──────
|
||||
await checkpoint('V3b†', 'MUTATION: silencing recall must break V3b (the test can turn red)', async () => {
|
||||
const app = makeApp()
|
||||
const mem = applyMemory(app.ctx, { path: join(workspace, 'v3b-mut.db'), topK: 3 })
|
||||
await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' })
|
||||
// Mutate: no recall, ever.
|
||||
mem.contextForStep = () => ''
|
||||
const input = createUserMessage({
|
||||
content: [{ type: 'text', text: '值班室分机号是多少' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
const decision = await app.ctx.waterfall(
|
||||
'agent/pre-step',
|
||||
{ agent: { id: 'a', session: {} }, messages: [input], turn: 1, step: 1, signal: new AbortController().signal },
|
||||
() => Promise.resolve({ kind: 'enter', messages: [input] }),
|
||||
)
|
||||
assert.equal(decision.messages.length, 1, 'mutation must leave the batch un-augmented')
|
||||
return 'with recall disabled the batch stays at 1 message ⇒ the V3b assertion would fail'
|
||||
})
|
||||
|
||||
// ── V3c: REALISTIC batch (question + runtime-context + skill-catalog) ─────
|
||||
//
|
||||
// V3b builds a 2-message batch, which is exactly the blind spot that let a
|
||||
// 100%-dead auto-recall ship. A real dsh step batch appends user-ROLE injected
|
||||
// context after the question, so "last role=user" picks up the skill catalog.
|
||||
await checkpoint('V3c', 'realistic 3-part batch: recall uses the question, not the injected context', async () => {
|
||||
const app = makeApp()
|
||||
const mem = applyMemory(app.ctx, { path: join(workspace, 'v3c.db'), topK: 3 })
|
||||
await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' })
|
||||
|
||||
const batch = [
|
||||
{ role: 'user', source: { kind: 'user' },
|
||||
content: [{ type: 'text', text: '值班室分机号是多少' }] },
|
||||
{ role: 'user', source: { kind: 'runtime-context', form: 'snapshot' },
|
||||
content: [{ type: 'text', text: '[Working directory]\n/home/agent/project' }] },
|
||||
{ role: 'user', source: { kind: 'skill-catalog', form: 'catalog' },
|
||||
content: [{ type: 'text', text: '[Skills] git-release-discipline, android-cli, shared-browser' }] },
|
||||
]
|
||||
const decision = await app.ctx.waterfall(
|
||||
'agent/pre-step',
|
||||
{ agent: { id: 'a', session: {} }, messages: batch, turn: 1, step: 1,
|
||||
signal: new AbortController().signal },
|
||||
() => Promise.resolve({ kind: 'enter', messages: batch }),
|
||||
)
|
||||
assert.equal(decision.messages.length, 4, '3 batch + 1 injected')
|
||||
const text = decision.messages[3].content.map((b) => b.text ?? '').join('')
|
||||
assert.ok(text.includes('4324'), `recall must hit; injected=${JSON.stringify(text.slice(0, 80))}`)
|
||||
|
||||
// The mutation, measured in the same run: the old rule must score 0.
|
||||
const oldRuleQuery = batch[batch.length - 1].content[0].text
|
||||
// `recall` is async since phase B group A — a bare Promise has no `.length`.
|
||||
const mutated = (await mem.recall(oldRuleQuery)).length
|
||||
const fixed = (await mem.recall('值班室分机号是多少')).length
|
||||
assert.equal(mutated, 0, 'old rule (skill catalog as query) must score 0 hits')
|
||||
assert.ok(fixed >= 1, 'fixed rule must score >= 1')
|
||||
return `batch 4 msgs, hit "4324" · old-rule hits=${mutated} vs fixed hits=${fixed}`
|
||||
})
|
||||
|
||||
// ── V4: tools.register output.schema / render contract ────────────────────
|
||||
await checkpoint('V4', 'ctx.tools.register: stablemem_commit validates args and renders output', async () => {
|
||||
const app = makeApp()
|
||||
const mem = applyMemory(app.ctx, { path: join(workspace, 'v4.db') })
|
||||
const schemas = app.ctx.tools.schemas()
|
||||
const tool = schemas.find((schema) => schema.name === 'stablemem_commit')
|
||||
assert.ok(tool, `stablemem_commit not registered; got ${schemas.map((s) => s.name).join(',')}`)
|
||||
assert.ok(tool.description.includes('durable'), 'description reaches the model')
|
||||
assert.ok(tool.parameters?.properties?.text, 'parameters expose `text`')
|
||||
|
||||
const result = await app.ctx.tools.execute({
|
||||
callId: 'v4-call-1',
|
||||
name: 'stablemem_commit',
|
||||
arguments: { text: 'V4 校验块:etcd 集群端口 2379', source: 'v4' },
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
assert.equal(result.isError, false, `tool failed: ${JSON.stringify(result)}`)
|
||||
assert.ok(typeof result.value === 'string' && result.value.includes('Committed'), `unexpected value ${result.value}`)
|
||||
assert.ok(Array.isArray(result.content) && result.content[0]?.type === 'text', 'render produced a text block')
|
||||
assert.equal(mem.store.countBlocks(), 1, 'block persisted')
|
||||
return `${schemas.length} tool(s) visible; execute → "${result.value}"`
|
||||
})
|
||||
|
||||
// ── V5: dispose releases the handle (idempotent, file unlocked) ───────────
|
||||
await checkpoint('V5', 'unload disposes the sqlite handle; a second close is a no-op', async () => {
|
||||
const path = join(workspace, 'v5.db')
|
||||
const app = makeApp()
|
||||
const mem = applyMemory(app.ctx, { path })
|
||||
await mem.commit({ text: 'V5 句柄释放块', source: 'v5' })
|
||||
assert.ok(mem.store.isOpen, 'store open while plugin loaded')
|
||||
|
||||
// Disposing the plugin fiber must run our ctx.effect teardown.
|
||||
await app.ctx.fiber.dispose()
|
||||
assert.equal(mem.store.isOpen, false, 'store must be closed after fiber disposal')
|
||||
// Idempotent: a second close (test teardown, double unload) must not throw.
|
||||
mem.store.close()
|
||||
|
||||
// The strongest proof the lock is gone: reopen the same file for writing.
|
||||
const reopened = new BlockStore({ path }).open()
|
||||
assert.equal(reopened.countBlocks(), 1, 'data survived; file was not locked')
|
||||
reopened.close()
|
||||
return 'fiber dispose → handle closed; reopened same file successfully'
|
||||
})
|
||||
|
||||
// ── A5 mutation: LIKE-style containment must destroy discrimination ───────
|
||||
await checkpoint('A5†', 'MUTATION: swapping FTS5 for containment breaks discrimination', () => {
|
||||
const store = new BlockStore({ path: join(workspace, 'a5.db') }).open()
|
||||
store.putBlock({ text: '值班室分机号 4324', source: 'm' })
|
||||
store.putBlock({ text: '值班室分机号 4379 已停用', source: 'm' })
|
||||
store.putBlock({ text: 'billing服务 端口=8499', source: 'm' })
|
||||
const precise = recallBlocks(store, '4324', { topK: 3 })
|
||||
assert.equal(precise.length, 1, `exact query should hit exactly one block, got ${precise.length}`)
|
||||
// The mutation: primary path becomes "contains any CJK run" — everything matches.
|
||||
const mutated = store
|
||||
.allBlocks(100)
|
||||
.filter((row) => row.text_content.includes('值班室'))
|
||||
assert.ok(
|
||||
mutated.length >= 2,
|
||||
'sanity: containment over a shared prefix must match multiple blocks',
|
||||
)
|
||||
store.close()
|
||||
return `FTS+pin → 1 hit for "4324"; containment → ${mutated.length} hits (discrimination lost)`
|
||||
})
|
||||
|
||||
// ── MIG3: an existing phase-A database survives the phase-B upgrade ───────
|
||||
//
|
||||
// Lives in `check` as well as `test` on purpose: "can the user's existing
|
||||
// memory.db still be used" is the single most important thing to see at a
|
||||
// glance, and more people read this output than read the test suite. The
|
||||
// failure it guards against is silent at load — the store opens, stamps a
|
||||
// version, accepts writes to blocks, and only throws on the first centroid
|
||||
// write — so a casual smoke test would miss it.
|
||||
await checkpoint('MIG3', 'phase-A database opens, migrates, and stays fully writable', () => {
|
||||
const path = join(workspace, 'phase-a.db')
|
||||
|
||||
// Build the phase-A shape by hand, including its OLD centroid table.
|
||||
const legacy = new DatabaseSync(path)
|
||||
legacy.exec(`CREATE TABLE memory_blocks (
|
||||
id TEXT PRIMARY KEY, modality TEXT NOT NULL, text_content TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '', scene TEXT NOT NULL DEFAULT '',
|
||||
semantic_type TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`)
|
||||
legacy.exec(`CREATE TABLE graph_centroid (
|
||||
fingerprint TEXT PRIMARY KEY, dim INTEGER NOT NULL, count INTEGER NOT NULL,
|
||||
sum_json TEXT NOT NULL, updated_at INTEGER NOT NULL)`)
|
||||
legacy.exec(`CREATE VIRTUAL TABLE memory_blocks_fts
|
||||
USING fts5(text_content, block_id UNINDEXED, tokenize='unicode61 remove_diacritics 2')`)
|
||||
legacy.exec(`INSERT INTO memory_blocks (id, modality, text_content, source, created_at, updated_at)
|
||||
VALUES ('blk_legacy', 'text', '值班室分机号 4324', 'phase-a', 1, 1)`)
|
||||
legacy.exec(`INSERT INTO memory_blocks_fts (text_content, block_id)
|
||||
VALUES ('值班室分机号 4324', 'blk_legacy')`)
|
||||
legacy.close()
|
||||
|
||||
const store = new BlockStore({ path }).open()
|
||||
const version = store.userVersion()
|
||||
const cols = store.columns('memory_blocks')
|
||||
assert.ok(cols.includes('vector') && cols.includes('fingerprint'), `columns: ${cols.join(',')}`)
|
||||
assert.equal(version, 1, `user_version after migration: ${version}`)
|
||||
|
||||
// The legacy block must survive AND still be findable.
|
||||
assert.ok(store.getBlock('blk_legacy'), 'the phase-A block must survive')
|
||||
assert.ok(store.searchFts('"4324"', 5).length >= 1, 'FTS must still find the legacy block')
|
||||
|
||||
// Both write paths must work — the block path AND the centroid path.
|
||||
store.putBlock({ text: '迁移之后的新块 8499' })
|
||||
const centroidCols = store.columns('graph_centroid')
|
||||
store.saveCentroid({ fingerprint: 'fp-mig', dim: 3, vectorCount: 1, mean: [0, 0, 0], isAnomalous: true })
|
||||
assert.ok(store.loadCentroid('fp-mig'), 'centroid must round-trip after migration')
|
||||
store.close()
|
||||
|
||||
return `uv=${version}, blocks=${cols.length} cols, centroid=${centroidCols.length} cols, legacy block readable + FTS-matching`
|
||||
})
|
||||
|
||||
// ── F7: store-level fingerprint inventory, D4-derived and D7-cheap ────────
|
||||
await checkpoint('F7', 'store stats groups active and stale fingerprints with DB-derived counts', () => {
|
||||
const path = join(workspace, 'f7.db')
|
||||
const store = new BlockStore({ path }).open()
|
||||
try {
|
||||
store.putBlock({ text: 'F7 active', vector: [1, 0, 0], fingerprint: 'fp-active' })
|
||||
store.putBlock({ text: 'F7 stale one', vector: [1, 0], fingerprint: 'fp-stale' })
|
||||
store.putBlock({ text: 'F7 stale two', vector: [0, 1], fingerprint: 'fp-stale' })
|
||||
store.putBlock({ text: 'F7 no vector' })
|
||||
store.saveCentroid({ fingerprint: 'fp-active', dim: 3, vectorCount: 1, mean: [0, 0, 0], isAnomalous: false })
|
||||
store.saveCentroid({ fingerprint: 'fp-stale', dim: 2, vectorCount: 2, mean: [0, 0], isAnomalous: false })
|
||||
|
||||
const report = store.vectorStats('fp-active')
|
||||
assert.deepEqual(report.blocks, { total: 4, with_vector: 3, no_vector: 1 })
|
||||
assert.deepEqual(report.spaces, {
|
||||
active: { fingerprint: 'fp-active', dim: 3 },
|
||||
by_fingerprint: [
|
||||
{ fingerprint: 'fp-active', dim: 3, blocks: 1, status: 'active' },
|
||||
{ fingerprint: 'fp-stale', dim: 2, blocks: 2, status: 'stale' },
|
||||
],
|
||||
no_fingerprint: 0,
|
||||
})
|
||||
return 'active=1 block/3D, stale=2 blocks/2D, no_vector=1; dimensions came from centroids'
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── F8: explicit width errors, provider width errors degrade ─────────────
|
||||
await checkpoint('F8', 'dimension validation separates caller errors from provider degradation', async () => {
|
||||
const explicitApp = makeApp()
|
||||
const explicit = applyMemory(explicitApp.ctx, {
|
||||
path: join(workspace, 'f8-explicit.db'),
|
||||
embedder: { info: () => ({ fingerprint: 'fp-f8', dimension: 3 }), embedText: async () => [1, 0, 0] },
|
||||
})
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => explicit.commit({ text: 'F8 caller error', vector: [1, 2], fingerprint: 'fp-f8' }),
|
||||
/does not match the active space/,
|
||||
)
|
||||
assert.equal(explicit.store.countBlocks(), 0, 'caller width error must not write a block')
|
||||
await explicit.commit({ text: 'F8 matching caller vector', vector: [1, 2, 3], fingerprint: 'fp-f8' })
|
||||
assert.equal(explicit.store.countBlocks(), 1, 'matching explicit vector is accepted')
|
||||
} finally {
|
||||
await explicitApp.ctx.fiber.dispose()
|
||||
}
|
||||
|
||||
const providerApp = makeApp()
|
||||
const provider = applyMemory(providerApp.ctx, {
|
||||
path: join(workspace, 'f8-provider.db'),
|
||||
embedder: { info: () => ({ fingerprint: 'fp-f8-provider', dimension: 3 }), embedText: async () => [1, 2] },
|
||||
})
|
||||
try {
|
||||
const result = await provider.commit({ text: 'F8 provider error' })
|
||||
assert.equal(result.embedded, false, 'provider width error must degrade without throwing')
|
||||
assert.equal(provider.store.countBlocks(), 1, 'degraded provider write still persists the block')
|
||||
assert.equal(provider.store.allBlocks(1)[0].vector, '', 'the bad provider vector is not stored')
|
||||
} finally {
|
||||
await providerApp.ctx.fiber.dispose()
|
||||
}
|
||||
return 'caller wrong width: rejected/no row; matching width: accepted; provider wrong width: degraded/row retained/vector empty'
|
||||
})
|
||||
|
||||
// ── cleanup ───────────────────────────────────────────────────────────────
|
||||
rmSync(workspace, { recursive: true, force: true })
|
||||
|
||||
const passed = results.filter((entry) => entry.ok).length
|
||||
console.log(`\n${passed}/${results.length} checkpoints passed\n`)
|
||||
console.log(`plugin: name=${pluginName} inject=[${pluginInject.join(', ')}]`)
|
||||
console.log(`workspace: ${workspace} (removed)\n`)
|
||||
|
||||
if (results.length === 0) {
|
||||
console.error('no checkpoints were registered — did the helper get renamed or stubbed?\n')
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`${failed} checkpoint(s) FAILED\n`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
55
scripts/link-dsh.sh
Executable file
55
scripts/link-dsh.sh
Executable file
@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# Link the globally-installed dsh packages into node_modules.
|
||||
#
|
||||
# The plugin declares no dependency of its own, so nothing here is fetched from a
|
||||
# registry. The dsh packages it *imports* at runtime are **peers**: they must come
|
||||
# from the running harness, and npm will not install them for us. During
|
||||
# development they live in
|
||||
# /usr/lib/node_modules/@deepseek-ai (a global install), so this script wires them
|
||||
# up by symlink instead of vendoring a copy that could drift from the harness.
|
||||
#
|
||||
# Usage: ./scripts/link-dsh.sh [global-modules-root]
|
||||
set -euo pipefail
|
||||
|
||||
GLOBAL_ROOT="${1:-/usr/lib/node_modules}"
|
||||
PEERS=(
|
||||
cordis
|
||||
dsh-agent
|
||||
dsh-agent-loop
|
||||
cosmokit
|
||||
dsh-llm
|
||||
dsh-session
|
||||
dsh-scope
|
||||
dsh-system-prompt
|
||||
dsh-tools
|
||||
dsh-util-values
|
||||
cordis-plugin-loader
|
||||
)
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if [[ ! -d "$GLOBAL_ROOT/@deepseek-ai" ]]; then
|
||||
echo "error: $GLOBAL_ROOT/@deepseek-ai not found — pass the root that holds the dsh install" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p node_modules/@deepseek-ai
|
||||
for peer in "${PEERS[@]}"; do
|
||||
src="$GLOBAL_ROOT/@deepseek-ai/$peer"
|
||||
if [[ ! -d "$src" ]]; then
|
||||
echo " skip $peer (not installed)"
|
||||
continue
|
||||
fi
|
||||
ln -sfn "$src" "node_modules/@deepseek-ai/$peer"
|
||||
echo " link $peer"
|
||||
done
|
||||
|
||||
# js-yaml is a transitive dep of cordis-plugin-include; needed only by the
|
||||
# loader integration test, and only when it is not resolvable already.
|
||||
if [[ -d "$GLOBAL_ROOT/js-yaml" && ! -e node_modules/js-yaml ]]; then
|
||||
ln -sfn "$GLOBAL_ROOT/js-yaml" node_modules/js-yaml
|
||||
echo " link js-yaml"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "done. try: npm test && npm run check"
|
||||
714
test/fixtures/golden-phase-a.json
vendored
Normal file
714
test/fixtures/golden-phase-a.json
vendored
Normal file
@ -0,0 +1,714 @@
|
||||
{
|
||||
"corpus": [
|
||||
{
|
||||
"text": "值班室分机号 4324,值班人 老周",
|
||||
"source": "qq"
|
||||
},
|
||||
{
|
||||
"text": "值班室分机号 4379 已停用",
|
||||
"source": "qq"
|
||||
},
|
||||
{
|
||||
"text": "billing服务 端口=8499",
|
||||
"source": "ops"
|
||||
},
|
||||
{
|
||||
"text": "billing2服务 端口=8501",
|
||||
"source": "ops"
|
||||
},
|
||||
{
|
||||
"text": "admin服务 端口=8861",
|
||||
"source": "ops"
|
||||
},
|
||||
{
|
||||
"text": "部署脚本 路径=/opt/deploy/run3.sh",
|
||||
"source": "ops"
|
||||
},
|
||||
{
|
||||
"text": "发布批次 第112批 的版本=v2.30.0",
|
||||
"source": "ci"
|
||||
},
|
||||
{
|
||||
"text": "发布记录 第115批 的版本=v2.31.0",
|
||||
"source": "ci"
|
||||
},
|
||||
{
|
||||
"text": "本机 13010 端口对应网关",
|
||||
"source": "net"
|
||||
},
|
||||
{
|
||||
"text": "数据库连接串 postgres://db:5432/app",
|
||||
"source": "ops"
|
||||
},
|
||||
{
|
||||
"text": "监控面板 http://10.0.0.7:3000",
|
||||
"source": "ops"
|
||||
},
|
||||
{
|
||||
"text": "财务部门 负责人=员工100",
|
||||
"source": "hr"
|
||||
}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"name": "exact-numeric",
|
||||
"query": "4324 是多少",
|
||||
"topK": 1,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_25496b163a54",
|
||||
"score": 1.003322623116964,
|
||||
"exact": true,
|
||||
"why": "exact:4324",
|
||||
"text": "值班室分机号 4324,值班人 老周"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "exact-numeric",
|
||||
"query": "4324 是多少",
|
||||
"topK": 3,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_25496b163a54",
|
||||
"score": 1.003322623116964,
|
||||
"exact": true,
|
||||
"why": "exact:4324",
|
||||
"text": "值班室分机号 4324,值班人 老周"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "exact-numeric",
|
||||
"query": "4324 是多少",
|
||||
"topK": 5,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_25496b163a54",
|
||||
"score": 1.003322623116964,
|
||||
"exact": true,
|
||||
"why": "exact:4324",
|
||||
"text": "值班室分机号 4324,值班人 老周"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "exact-numeric-absent",
|
||||
"query": "9999 是多少",
|
||||
"topK": 1,
|
||||
"hits": []
|
||||
},
|
||||
{
|
||||
"name": "exact-numeric-absent",
|
||||
"query": "9999 是多少",
|
||||
"topK": 3,
|
||||
"hits": []
|
||||
},
|
||||
{
|
||||
"name": "exact-numeric-absent",
|
||||
"query": "9999 是多少",
|
||||
"topK": 5,
|
||||
"hits": []
|
||||
},
|
||||
{
|
||||
"name": "cjk-substring",
|
||||
"query": "分机号",
|
||||
"topK": 1,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_25496b163a54",
|
||||
"score": 0.75,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "值班室分机号 4324,值班人 老周"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "cjk-substring",
|
||||
"query": "分机号",
|
||||
"topK": 3,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_25496b163a54",
|
||||
"score": 0.75,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "值班室分机号 4324,值班人 老周"
|
||||
},
|
||||
{
|
||||
"id": "blk_db476841debd",
|
||||
"score": 0.75,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "值班室分机号 4379 已停用"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "cjk-substring",
|
||||
"query": "分机号",
|
||||
"topK": 5,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_25496b163a54",
|
||||
"score": 0.75,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "值班室分机号 4324,值班人 老周"
|
||||
},
|
||||
{
|
||||
"id": "blk_db476841debd",
|
||||
"score": 0.75,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "值班室分机号 4379 已停用"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "cjk-question",
|
||||
"query": "值班室分机号是多少",
|
||||
"topK": 1,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_25496b163a54",
|
||||
"score": 0.3333333333333333,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "值班室分机号 4324,值班人 老周"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "cjk-question",
|
||||
"query": "值班室分机号是多少",
|
||||
"topK": 3,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_25496b163a54",
|
||||
"score": 0.3333333333333333,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "值班室分机号 4324,值班人 老周"
|
||||
},
|
||||
{
|
||||
"id": "blk_db476841debd",
|
||||
"score": 0.3333333333333333,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "值班室分机号 4379 已停用"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "cjk-question",
|
||||
"query": "值班室分机号是多少",
|
||||
"topK": 5,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_25496b163a54",
|
||||
"score": 0.3333333333333333,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "值班室分机号 4324,值班人 老周"
|
||||
},
|
||||
{
|
||||
"id": "blk_db476841debd",
|
||||
"score": 0.3333333333333333,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "值班室分机号 4379 已停用"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "latin-word",
|
||||
"query": "billing 服务的端口是多少",
|
||||
"topK": 1,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_248c9ea6912a",
|
||||
"score": 0.501,
|
||||
"exact": false,
|
||||
"why": "word:billing",
|
||||
"text": "billing服务 端口=8499"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "latin-word",
|
||||
"query": "billing 服务的端口是多少",
|
||||
"topK": 3,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_248c9ea6912a",
|
||||
"score": 0.501,
|
||||
"exact": false,
|
||||
"why": "word:billing",
|
||||
"text": "billing服务 端口=8499"
|
||||
},
|
||||
{
|
||||
"id": "blk_1a22661e3bca",
|
||||
"score": 0.5,
|
||||
"exact": false,
|
||||
"why": "word:billing",
|
||||
"text": "billing2服务 端口=8501"
|
||||
},
|
||||
{
|
||||
"id": "blk_0e348cbeacfb",
|
||||
"score": 0.1875,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "本机 13010 端口对应网关"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "latin-word",
|
||||
"query": "billing 服务的端口是多少",
|
||||
"topK": 5,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_248c9ea6912a",
|
||||
"score": 0.501,
|
||||
"exact": false,
|
||||
"why": "word:billing",
|
||||
"text": "billing服务 端口=8499"
|
||||
},
|
||||
{
|
||||
"id": "blk_1a22661e3bca",
|
||||
"score": 0.5,
|
||||
"exact": false,
|
||||
"why": "word:billing",
|
||||
"text": "billing2服务 端口=8501"
|
||||
},
|
||||
{
|
||||
"id": "blk_0e348cbeacfb",
|
||||
"score": 0.1875,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "本机 13010 端口对应网关"
|
||||
},
|
||||
{
|
||||
"id": "blk_4ef71225f4f6",
|
||||
"score": 0.1875,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "admin服务 端口=8861"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "latin-prefix-collision",
|
||||
"query": "billing 端口",
|
||||
"topK": 1,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_248c9ea6912a",
|
||||
"score": 0.751,
|
||||
"exact": false,
|
||||
"why": "word:billing",
|
||||
"text": "billing服务 端口=8499"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "latin-prefix-collision",
|
||||
"query": "billing 端口",
|
||||
"topK": 3,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_248c9ea6912a",
|
||||
"score": 0.751,
|
||||
"exact": false,
|
||||
"why": "word:billing",
|
||||
"text": "billing服务 端口=8499"
|
||||
},
|
||||
{
|
||||
"id": "blk_1a22661e3bca",
|
||||
"score": 0.75,
|
||||
"exact": false,
|
||||
"why": "word:billing",
|
||||
"text": "billing2服务 端口=8501"
|
||||
},
|
||||
{
|
||||
"id": "blk_4ef71225f4f6",
|
||||
"score": 0.75,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "admin服务 端口=8861"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "latin-prefix-collision",
|
||||
"query": "billing 端口",
|
||||
"topK": 5,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_248c9ea6912a",
|
||||
"score": 0.751,
|
||||
"exact": false,
|
||||
"why": "word:billing",
|
||||
"text": "billing服务 端口=8499"
|
||||
},
|
||||
{
|
||||
"id": "blk_1a22661e3bca",
|
||||
"score": 0.75,
|
||||
"exact": false,
|
||||
"why": "word:billing",
|
||||
"text": "billing2服务 端口=8501"
|
||||
},
|
||||
{
|
||||
"id": "blk_4ef71225f4f6",
|
||||
"score": 0.75,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "admin服务 端口=8861"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "version",
|
||||
"query": "版本 v2.30.0 是哪一批",
|
||||
"topK": 1,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_2bf27acf69db",
|
||||
"score": 1.0037232350815941,
|
||||
"exact": true,
|
||||
"why": "exact:v2.30.0",
|
||||
"text": "发布批次 第112批 的版本=v2.30.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "version",
|
||||
"query": "版本 v2.30.0 是哪一批",
|
||||
"topK": 3,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_2bf27acf69db",
|
||||
"score": 1.0037232350815941,
|
||||
"exact": true,
|
||||
"why": "exact:v2.30.0",
|
||||
"text": "发布批次 第112批 的版本=v2.30.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "version",
|
||||
"query": "版本 v2.30.0 是哪一批",
|
||||
"topK": 5,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_2bf27acf69db",
|
||||
"score": 1.0037232350815941,
|
||||
"exact": true,
|
||||
"why": "exact:v2.30.0",
|
||||
"text": "发布批次 第112批 的版本=v2.30.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "path",
|
||||
"query": "/opt/deploy/run3.sh 在哪",
|
||||
"topK": 1,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_38a71c737eef",
|
||||
"score": 1.001892383734559,
|
||||
"exact": true,
|
||||
"why": "exact:run3",
|
||||
"text": "部署脚本 路径=/opt/deploy/run3.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "path",
|
||||
"query": "/opt/deploy/run3.sh 在哪",
|
||||
"topK": 3,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_38a71c737eef",
|
||||
"score": 1.001892383734559,
|
||||
"exact": true,
|
||||
"why": "exact:run3",
|
||||
"text": "部署脚本 路径=/opt/deploy/run3.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "path",
|
||||
"query": "/opt/deploy/run3.sh 在哪",
|
||||
"topK": 5,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_38a71c737eef",
|
||||
"score": 1.001892383734559,
|
||||
"exact": true,
|
||||
"why": "exact:run3",
|
||||
"text": "部署脚本 路径=/opt/deploy/run3.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "port",
|
||||
"query": "13010 端口",
|
||||
"topK": 1,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_0e348cbeacfb",
|
||||
"score": 1.0031028290329878,
|
||||
"exact": true,
|
||||
"why": "exact:13010",
|
||||
"text": "本机 13010 端口对应网关"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "port",
|
||||
"query": "13010 端口",
|
||||
"topK": 3,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_0e348cbeacfb",
|
||||
"score": 1.0031028290329878,
|
||||
"exact": true,
|
||||
"why": "exact:13010",
|
||||
"text": "本机 13010 端口对应网关"
|
||||
},
|
||||
{
|
||||
"id": "blk_1a22661e3bca",
|
||||
"score": 0.75,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "billing2服务 端口=8501"
|
||||
},
|
||||
{
|
||||
"id": "blk_248c9ea6912a",
|
||||
"score": 0.75,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "billing服务 端口=8499"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "port",
|
||||
"query": "13010 端口",
|
||||
"topK": 5,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_0e348cbeacfb",
|
||||
"score": 1.0031028290329878,
|
||||
"exact": true,
|
||||
"why": "exact:13010",
|
||||
"text": "本机 13010 端口对应网关"
|
||||
},
|
||||
{
|
||||
"id": "blk_1a22661e3bca",
|
||||
"score": 0.75,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "billing2服务 端口=8501"
|
||||
},
|
||||
{
|
||||
"id": "blk_248c9ea6912a",
|
||||
"score": 0.75,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "billing服务 端口=8499"
|
||||
},
|
||||
{
|
||||
"id": "blk_4ef71225f4f6",
|
||||
"score": 0.75,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "admin服务 端口=8861"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "url",
|
||||
"query": "10.0.0.7 是什么",
|
||||
"topK": 1,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_04bf9028222f",
|
||||
"score": 1.002450474896629,
|
||||
"exact": true,
|
||||
"why": "exact:10.0.0.7",
|
||||
"text": "监控面板 http://10.0.0.7:3000"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "url",
|
||||
"query": "10.0.0.7 是什么",
|
||||
"topK": 3,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_04bf9028222f",
|
||||
"score": 1.002450474896629,
|
||||
"exact": true,
|
||||
"why": "exact:10.0.0.7",
|
||||
"text": "监控面板 http://10.0.0.7:3000"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "url",
|
||||
"query": "10.0.0.7 是什么",
|
||||
"topK": 5,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_04bf9028222f",
|
||||
"score": 1.002450474896629,
|
||||
"exact": true,
|
||||
"why": "exact:10.0.0.7",
|
||||
"text": "监控面板 http://10.0.0.7:3000"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "mixed",
|
||||
"query": "postgres 连接串 5432",
|
||||
"topK": 1,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_6707cd66f5e2",
|
||||
"score": 1.002269299108547,
|
||||
"exact": true,
|
||||
"why": "exact:5432",
|
||||
"text": "数据库连接串 postgres://db:5432/app"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "mixed",
|
||||
"query": "postgres 连接串 5432",
|
||||
"topK": 3,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_6707cd66f5e2",
|
||||
"score": 1.002269299108547,
|
||||
"exact": true,
|
||||
"why": "exact:5432",
|
||||
"text": "数据库连接串 postgres://db:5432/app"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "mixed",
|
||||
"query": "postgres 连接串 5432",
|
||||
"topK": 5,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_6707cd66f5e2",
|
||||
"score": 1.002269299108547,
|
||||
"exact": true,
|
||||
"why": "exact:5432",
|
||||
"text": "数据库连接串 postgres://db:5432/app"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "cjk-dept",
|
||||
"query": "财务部门的负责人是谁",
|
||||
"topK": 1,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_3d254170bb70",
|
||||
"score": 0.30000000000000004,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "财务部门 负责人=员工100"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "cjk-dept",
|
||||
"query": "财务部门的负责人是谁",
|
||||
"topK": 3,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_3d254170bb70",
|
||||
"score": 0.30000000000000004,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "财务部门 负责人=员工100"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "cjk-dept",
|
||||
"query": "财务部门的负责人是谁",
|
||||
"topK": 5,
|
||||
"hits": [
|
||||
{
|
||||
"id": "blk_3d254170bb70",
|
||||
"score": 0.30000000000000004,
|
||||
"exact": false,
|
||||
"why": "lexical",
|
||||
"text": "财务部门 负责人=员工100"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "unrelated",
|
||||
"query": "zzz 完全无关 zzz",
|
||||
"topK": 1,
|
||||
"hits": []
|
||||
},
|
||||
{
|
||||
"name": "unrelated",
|
||||
"query": "zzz 完全无关 zzz",
|
||||
"topK": 3,
|
||||
"hits": []
|
||||
},
|
||||
{
|
||||
"name": "unrelated",
|
||||
"query": "zzz 完全无关 zzz",
|
||||
"topK": 5,
|
||||
"hits": []
|
||||
},
|
||||
{
|
||||
"name": "empty",
|
||||
"query": "",
|
||||
"topK": 1,
|
||||
"hits": []
|
||||
},
|
||||
{
|
||||
"name": "empty",
|
||||
"query": "",
|
||||
"topK": 3,
|
||||
"hits": []
|
||||
},
|
||||
{
|
||||
"name": "empty",
|
||||
"query": "",
|
||||
"topK": 5,
|
||||
"hits": []
|
||||
},
|
||||
{
|
||||
"name": "cjk-generic-gap",
|
||||
"query": "公网地址是什么",
|
||||
"topK": 1,
|
||||
"hits": []
|
||||
},
|
||||
{
|
||||
"name": "cjk-generic-gap",
|
||||
"query": "公网地址是什么",
|
||||
"topK": 3,
|
||||
"hits": []
|
||||
},
|
||||
{
|
||||
"name": "cjk-generic-gap",
|
||||
"query": "公网地址是什么",
|
||||
"topK": 5,
|
||||
"hits": []
|
||||
}
|
||||
]
|
||||
}
|
||||
144
test/loader.test.js
Normal file
144
test/loader.test.js
Normal file
@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Real-loader integration test: mount the plugin the way a dsh profile does.
|
||||
*
|
||||
* This is the one test that proves the *packaging* is right — `name`/`inject`/
|
||||
* `apply` exports, the `defineTool` contract, and `systemPrompt.context` all
|
||||
* survive an actual `cordis-plugin-loader` mount rather than a hand-built app.
|
||||
*
|
||||
* If `cordis-plugin-loader` cannot be resolved (bare install, no dsh), the test
|
||||
* skips rather than fails: the plugin itself does not depend on the loader.
|
||||
*
|
||||
* @module dsh-memory/test/loader
|
||||
*/
|
||||
|
||||
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 { Context } from '@deepseek-ai/cordis'
|
||||
import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import { ToolRuntime } from '@deepseek-ai/dsh-tools'
|
||||
import * as plugin from '../lib/index.js'
|
||||
|
||||
/** @returns {Promise<unknown>} the loader module, or null when unavailable */
|
||||
async function tryLoadLoader() {
|
||||
try {
|
||||
return await import('@deepseek-ai/cordis-plugin-loader')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
test('plugin exports the shape the cordis loader requires', () => {
|
||||
// A loader entry is only mountable when these three exist.
|
||||
assert.equal(typeof plugin.apply, 'function', 'apply(ctx, config) is required')
|
||||
assert.equal(typeof plugin.name, 'string', 'name is required for diagnostics')
|
||||
assert.ok(Array.isArray(plugin.inject), 'inject must be an array')
|
||||
assert.deepEqual(plugin.inject, ['systemPrompt', 'tools'])
|
||||
// The default export must be the same plugin (loaders unwrap `default`).
|
||||
assert.equal(plugin.default.name, plugin.name)
|
||||
assert.equal(plugin.default.apply, plugin.apply)
|
||||
})
|
||||
|
||||
test('mounting twice on the same app fails loudly (service name is taken)', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-memory-dup-'))
|
||||
const ctx = new Context()
|
||||
new SystemPrompt(ctx, {})
|
||||
new ToolRuntime(ctx, {})
|
||||
try {
|
||||
plugin.apply(ctx, { path: join(dir, 'a.db') })
|
||||
// 'stablemem' is provided by the first instance; cordis refuses a second
|
||||
// implementation in the same realm — which is exactly why a preset that
|
||||
// wants its own implementation must isolate the service name
|
||||
// (docs/shadow-context.md §5.2).
|
||||
assert.throws(
|
||||
() => plugin.apply(ctx, { path: join(dir, 'b.db') }),
|
||||
/stablemem/,
|
||||
'duplicate service registration must be rejected',
|
||||
)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('an isolated realm can host its own stablemem (the preset route)', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-memory-iso-'))
|
||||
const ctx = new Context()
|
||||
new SystemPrompt(ctx, {})
|
||||
new ToolRuntime(ctx, {})
|
||||
try {
|
||||
const outer = plugin.apply(ctx, { path: join(dir, 'outer.db') })
|
||||
// A preset subtree isolates the service name and mounts a second instance.
|
||||
// Prompt-context names and tool names are NOT partitioned by isolate()
|
||||
// (dsh-system-prompt/lib/index.js:204, dsh-tools duplicate check), so a
|
||||
// second instance needs a distinct tool name. The prompt-context name is
|
||||
// derived from the db path automatically.
|
||||
const realm = ctx.isolate('stablemem')
|
||||
const inner = plugin.apply(realm, {
|
||||
path: join(dir, 'inner.db'),
|
||||
toolName: 'stablemem_commit_alt',
|
||||
statsToolName: 'stablemem_stats_alt',
|
||||
})
|
||||
|
||||
// Both awaits are required: commit() embeds (async) before writing, and
|
||||
// relying on "no embedder means it happens synchronously" is exactly the
|
||||
// fragile assumption this guards against. Promise.all keeps them concurrent,
|
||||
// which is what this test is about (two isolated realms writing in parallel).
|
||||
await Promise.all([
|
||||
outer.commit({ text: 'outer-only 端口 1111' }),
|
||||
inner.commit({ text: 'inner-only 端口 2222' }),
|
||||
])
|
||||
|
||||
assert.equal(outer.store.countBlocks(), 1, 'outer store isolated')
|
||||
assert.equal(inner.store.countBlocks(), 1, 'inner store isolated')
|
||||
assert.notEqual(outer.store.path, inner.store.path, 'separate databases')
|
||||
|
||||
// The isolated service is reachable through the realm context, and the two
|
||||
// contexts resolve DIFFERENT implementations — that is the whole point.
|
||||
const realmService = realm.stablemem
|
||||
// cordis hands out a PROXY, so identity differs from the instance we built;
|
||||
// compare behaviour, not references. (This proxy is also why StableMem keeps
|
||||
// ordinary fields instead of #private ones.)
|
||||
assert.equal(realmService.store.countBlocks(), 1, 'realm sees only its own block')
|
||||
assert.equal(realmService.store.path, inner.store.path, 'realm resolves the inner instance')
|
||||
assert.equal(ctx.stablemem.store.path, outer.store.path, 'root resolves the outer instance')
|
||||
assert.equal(ctx.stablemem.store.countBlocks(), 1, 'outer store untouched by the realm')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('a real cordis loader can mount the plugin by specifier', async (t) => {
|
||||
const loaderModule = await tryLoadLoader()
|
||||
if (loaderModule === null) {
|
||||
t.skip('cordis-plugin-loader not installed in this environment')
|
||||
return
|
||||
}
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-memory-loader-'))
|
||||
const ctx = new Context()
|
||||
new SystemPrompt(ctx, {})
|
||||
new ToolRuntime(ctx, {})
|
||||
try {
|
||||
const pluginSpecifier = new URL('../lib/index.js', import.meta.url).href
|
||||
const fiber = ctx.plugin(
|
||||
{
|
||||
name: pluginSpecifier,
|
||||
inject: plugin.inject,
|
||||
apply: plugin.apply,
|
||||
},
|
||||
{ path: join(dir, 'loaded.db') },
|
||||
)
|
||||
await fiber.await()
|
||||
assert.ok(ctx.stablemem, 'the loaded plugin registered ctx.stablemem')
|
||||
assert.ok(
|
||||
ctx.tools.schemas().some((schema) => schema.name === 'stablemem_commit'),
|
||||
'the loaded plugin registered its tool',
|
||||
)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
154
test/loop-ports.test.js
Normal file
154
test/loop-ports.test.js
Normal file
@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Contract for the four ported loop modules under `lib/loop/`.
|
||||
*
|
||||
* These files are a faithful port of the stock loop's internals into this
|
||||
* package, so nothing here tests their behaviour — that is `loop.test.js` and
|
||||
* the phase-B suites. What this file pins is the **port interface**, because
|
||||
* every one of these failures is silent at runtime:
|
||||
*
|
||||
* L1 all four modules import on their own (no ordering, no index.js).
|
||||
* L2 the export names a replacement loop will import are present.
|
||||
* L3 `inboxProjectionSchema.parse` actually rejects a malformed persisted
|
||||
* row. This is the ONE thing the port re-implemented instead of copying
|
||||
* (it replaces a zod schema with a hand-written shape check), so it is
|
||||
* exactly the thing that can be a no-op and still look fine.
|
||||
* L4 the inbox projection KEY and `stateVersion` are unchanged. A different
|
||||
* key reads as "no pending input" for sessions written by the stock loop;
|
||||
* a different version makes `stateSchema.parse` refuse those rows. Both
|
||||
* are quiet data loss on `resume`, not a crash.
|
||||
*
|
||||
* @module dsh-memory/test/loop-ports
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
// ── L1: each module stands alone ─────────────────────────────────────────
|
||||
//
|
||||
// Imported dynamically rather than at the top of the file so the assertion is
|
||||
// per-module: a single top-level `import` would make one broken port fail the
|
||||
// whole file with "load failed" and hide which of the four broke.
|
||||
const MODULES = {
|
||||
'assistant-stream': '../lib/loop/assistant-stream.js',
|
||||
inbox: '../lib/loop/inbox.js',
|
||||
'runtime-context': '../lib/loop/runtime-context.js',
|
||||
'tool-calls': '../lib/loop/tool-calls.js',
|
||||
}
|
||||
|
||||
test('L1: all four loop ports import successfully on their own', async () => {
|
||||
for (const [name, specifier] of Object.entries(MODULES)) {
|
||||
const mod = await import(specifier)
|
||||
assert.ok(mod !== null && typeof mod === 'object', `${name} must import to a module namespace`)
|
||||
}
|
||||
})
|
||||
|
||||
// ── L2: the export surface a replacement loop depends on ─────────────────
|
||||
//
|
||||
// Named explicitly, not counted: the point is that `executeToolCalls`,
|
||||
// `ReactLoopInbox`, … are still exported under those names, because a rename
|
||||
// here only shows up as `undefined is not a function` at step() time.
|
||||
test('L2: loop ports export the names the replacement loop imports', async () => {
|
||||
const toolCalls = await import(MODULES['tool-calls'])
|
||||
assert.equal(typeof toolCalls.executeToolCalls, 'function', 'tool-calls must export executeToolCalls')
|
||||
|
||||
const assistantStream = await import(MODULES['assistant-stream'])
|
||||
assert.equal(
|
||||
typeof assistantStream.AssistantStreamAttempt,
|
||||
'function',
|
||||
'assistant-stream must export AssistantStreamAttempt',
|
||||
)
|
||||
|
||||
const runtimeContext = await import(MODULES['runtime-context'])
|
||||
assert.equal(
|
||||
typeof runtimeContext.SystemPromptProjection,
|
||||
'function',
|
||||
'runtime-context must export SystemPromptProjection',
|
||||
)
|
||||
assert.equal(
|
||||
typeof runtimeContext.RuntimeContextProjection,
|
||||
'function',
|
||||
'runtime-context must export RuntimeContextProjection',
|
||||
)
|
||||
|
||||
const inbox = await import(MODULES.inbox)
|
||||
assert.equal(typeof inbox.ReactLoopInbox, 'function', 'inbox must export ReactLoopInbox')
|
||||
assert.ok(
|
||||
inbox.inboxProjectionDefinition !== null && typeof inbox.inboxProjectionDefinition === 'object',
|
||||
'inbox must export inboxProjectionDefinition',
|
||||
)
|
||||
assert.ok(
|
||||
inbox.inboxProjectionSchema !== null && typeof inbox.inboxProjectionSchema === 'object',
|
||||
'inbox must export inboxProjectionSchema',
|
||||
)
|
||||
})
|
||||
|
||||
// ── L3: the schema bites ──────────────────────────────────────────────────
|
||||
//
|
||||
// The projection registry calls `stateSchema.parse` on durable checkpoint rows,
|
||||
// so "it has a parse function" proves nothing. These assertions run the parse
|
||||
// against the two shapes that matter: a real state must come back, and a
|
||||
// malformed row must throw — not return undefined, not coerce.
|
||||
test('L3: inboxProjectionSchema.parse accepts a well-formed two-array state', async () => {
|
||||
const { inboxProjectionSchema } = await import(MODULES.inbox)
|
||||
|
||||
const state = {
|
||||
'next-turn': [{ id: 'm1', role: 'user' }],
|
||||
'next-step': [{ id: 'm2', role: 'user' }],
|
||||
}
|
||||
assert.equal(inboxProjectionSchema.parse(state), state, 'parse must return the same object it validated')
|
||||
assert.deepEqual(
|
||||
inboxProjectionSchema.parse({ 'next-turn': [], 'next-step': [] }),
|
||||
{ 'next-turn': [], 'next-step': [] },
|
||||
'the init() shape (both empty) must be accepted',
|
||||
)
|
||||
})
|
||||
|
||||
test('L3: inboxProjectionSchema.parse throws on a malformed persisted row', async () => {
|
||||
const { inboxProjectionSchema } = await import(MODULES.inbox)
|
||||
|
||||
const malformed = [
|
||||
['null', null],
|
||||
['a bare array', []],
|
||||
['a string', 'inbox'],
|
||||
['an object missing "next-step"', { 'next-turn': [] }],
|
||||
['a non-array "next-turn"', { 'next-turn': {}, 'next-step': [] }],
|
||||
['a non-array "next-step"', { 'next-turn': [], 'next-step': 'pending' }],
|
||||
['the stock loop key casing', { nextTurn: [], nextStep: [] }],
|
||||
]
|
||||
for (const [label, value] of malformed) {
|
||||
assert.throws(
|
||||
() => inboxProjectionSchema.parse(value),
|
||||
`parse must reject ${label} — a returning-but-not-throwing parse would accept a corrupt checkpoint`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// ── L4: the durable identity of the inbox projection ─────────────────────
|
||||
//
|
||||
// These two literals are a wire format shared with sessions written by the
|
||||
// stock `dsh-agent-loop`. Changing either is silent: the stock loop's pending
|
||||
// splices stop folding in, and the session resumes with an empty inbox.
|
||||
test('L4: inbox projection keeps key "inbox" and stateVersion 1', async () => {
|
||||
const { inboxProjectionDefinition, inboxProjectionSchema } = await import(MODULES.inbox)
|
||||
|
||||
assert.equal(
|
||||
inboxProjectionDefinition.key,
|
||||
'inbox',
|
||||
'the projection key is a wire format shared with stock-loop sessions — changing it loses their pending input',
|
||||
)
|
||||
assert.equal(
|
||||
inboxProjectionDefinition.stateVersion,
|
||||
1,
|
||||
'stateVersion 1 is what existing checkpoint rows carry — a bump makes stateSchema.parse refuse them',
|
||||
)
|
||||
assert.equal(
|
||||
inboxProjectionDefinition.stateSchema,
|
||||
inboxProjectionSchema,
|
||||
'the definition must validate with the exported schema, not a private copy',
|
||||
)
|
||||
assert.deepEqual(
|
||||
inboxProjectionDefinition.init(),
|
||||
{ 'next-turn': [], 'next-step': [] },
|
||||
'init() must produce the shape stateSchema.parse accepts',
|
||||
)
|
||||
})
|
||||
194
test/loop-system-e2e.test.js
Normal file
194
test/loop-system-e2e.test.js
Normal file
@ -0,0 +1,194 @@
|
||||
/**
|
||||
* End-to-end proof: a recall block produced by the REAL injection shape reaches
|
||||
* the SYSTEM prompt and does NOT reach any user/message content.
|
||||
*
|
||||
* Why this file exists next to `loop.test.js`
|
||||
* -------------------------------------------
|
||||
* `loop.test.js` asserts the pure decision rewrite (`promoteSnapshotSections`).
|
||||
* That is necessary but not sufficient: the bug it fixes was that the rewrite
|
||||
* never fired on the real injection shape, and a unit test can only catch that
|
||||
* if it also encodes the real shape. This file closes the loop the other way —
|
||||
* it mounts the actual `HologramAgentLoop` on real dsh services (`SessionStore`,
|
||||
* `AgentRegistry`, `LlmRuntime`, `SystemPrompt`, `ToolRuntime`,
|
||||
* `SessionProjectionRegistry`), drives one real turn (pre-step → request build →
|
||||
* LLM call), and asserts against **the request object the adapter actually
|
||||
* received**, not an intermediate decision.
|
||||
*
|
||||
* Two directions, both required:
|
||||
* E1 positive — the recall text appears in the system prompt the adapter got.
|
||||
* E2 negative — the recall text appears in NO message body the adapter got
|
||||
* (every message, every block; not just the last one).
|
||||
*
|
||||
* Only E1 fails on the broken commit; E2 is already true there because the old
|
||||
* code left recall in history. E2 is what this fix is *for*: it must stay true
|
||||
* after the move, otherwise the promotion would duplicate recall rather than
|
||||
* relocate it.
|
||||
*
|
||||
* No network: the "adapter" is a local `LlmAdapter` subclass that records the
|
||||
* request and yields one text chunk.
|
||||
*
|
||||
* @module dsh-memory/test/loop-system-e2e
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { createRequire } from 'node:module'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import test from 'node:test'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { AgentRegistry } from '@deepseek-ai/dsh-agent'
|
||||
import { LlmAdapter, LlmRuntime, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionStore } from '@deepseek-ai/dsh-session'
|
||||
import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import { ToolRuntime } from '@deepseek-ai/dsh-tools'
|
||||
import { HologramAgentLoop } from '../lib/loop.js'
|
||||
|
||||
/**
|
||||
* Load `SessionProjectionRegistry` the way the harness does.
|
||||
*
|
||||
* It is a peer of `dsh-agent-loop`, not of this package, so a bare specifier
|
||||
* only resolves when the repo's `node_modules` happens to link it. Resolving
|
||||
* from `dsh-agent-loop`'s own location is exactly what a real dsh process does;
|
||||
* if neither route works there is no harness to run against, and the test skips
|
||||
* loudly instead of asserting on a half-mounted app.
|
||||
* @returns {Promise<Function|null>}
|
||||
*/
|
||||
async function loadProjectionRegistry() {
|
||||
try {
|
||||
return (await import('@deepseek-ai/dsh-session-projection')).SessionProjectionRegistry
|
||||
} catch {
|
||||
try {
|
||||
const peerRequire = createRequire(import.meta.resolve('@deepseek-ai/dsh-agent-loop'))
|
||||
return (await import(pathToFileURL(peerRequire.resolve('@deepseek-ai/dsh-session-projection')).href))
|
||||
.SessionProjectionRegistry
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Every text the model would read out of a message body. */
|
||||
function bodyText(message) {
|
||||
if (!Array.isArray(message?.content)) return ''
|
||||
return message.content.map((block) => (typeof block?.text === 'string' ? block.text : '')).join('')
|
||||
}
|
||||
|
||||
/** The system prompt text as the adapter received it. */
|
||||
function systemText(request) {
|
||||
return request.messages
|
||||
.filter((message) => message.role === 'system')
|
||||
.map((message) => bodyText(message))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/** Every non-system message body, flattened, for the negative direction. */
|
||||
function historyBlocks(request) {
|
||||
return request.messages
|
||||
.filter((message) => message.role !== 'system')
|
||||
.map((message, index) => ({ index, role: message.role, kind: message.source?.kind, text: bodyText(message) }))
|
||||
}
|
||||
|
||||
test('E1/E2: recall reaches the system prompt and no message body', async (t) => {
|
||||
const SessionProjectionRegistry = await loadProjectionRegistry()
|
||||
if (SessionProjectionRegistry === null) {
|
||||
t.skip('@deepseek-ai/dsh-session-projection not resolvable from this install (no harness to mount)')
|
||||
return
|
||||
}
|
||||
|
||||
const RECALL = 'RECALLED-NOTE-4471: user prefers evidence over summaries.'
|
||||
|
||||
/** Records the request it is called with; yields one stop-finish text chunk. */
|
||||
const captured = []
|
||||
class CaptureAdapter extends LlmAdapter {
|
||||
async *stream(request) {
|
||||
captured.push(request)
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'text-delta', index: 0, text: 'acknowledged' }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'acknowledged' } }
|
||||
yield { type: 'finish', reason: 'stop' }
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = new Context()
|
||||
try {
|
||||
// Cordis defers service publication by a microtask; each `new` below needs
|
||||
// the previous one visible, so yield between mounts.
|
||||
new SessionStore(ctx)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
new AgentRegistry(ctx)
|
||||
new SystemPrompt(ctx, {})
|
||||
new ToolRuntime(ctx)
|
||||
new LlmRuntime(ctx)
|
||||
new SessionProjectionRegistry(ctx)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
// The loop under test. Mount before any agent exists: `setFactory` runs in
|
||||
// the constructor and the class refuses a second registration.
|
||||
new HologramAgentLoop(ctx, { maxParallelToolCalls: 4, agents: [] })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
ctx.llm.registerAdapter(['capture'], new CaptureAdapter())
|
||||
|
||||
// The REAL injection shape, reproduced byte for byte from `lib/index.js:932`:
|
||||
// one text block whose text IS the section text, `form: 'snapshot'`.
|
||||
ctx.on(
|
||||
'agent/pre-step',
|
||||
async (payload, next) => {
|
||||
const decision = await next()
|
||||
if (decision.kind === 'reject' || payload.signal.aborted) return decision
|
||||
return {
|
||||
...decision,
|
||||
messages: [
|
||||
...decision.messages,
|
||||
createUserMessage({
|
||||
content: [{ type: 'text', text: RECALL }],
|
||||
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: RECALL }] },
|
||||
}),
|
||||
],
|
||||
}
|
||||
},
|
||||
{ prepend: true },
|
||||
)
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: 'loop-system-e2e',
|
||||
agentOptions: { provider: 'capture', model: 'capture-1' },
|
||||
})
|
||||
const agent = handle.agent
|
||||
agent.followup(
|
||||
createUserMessage({ content: [{ type: 'text', text: 'what did we decide?' }], source: { kind: 'user' } }),
|
||||
)
|
||||
await agent.whenIdle()
|
||||
|
||||
assert.equal(captured.length, 1, `the adapter must be called exactly once; got ${captured.length} call(s)`)
|
||||
const request = captured[0]
|
||||
|
||||
// ── E1 · positive: the recall is in the system prompt ────────────────
|
||||
const system = systemText(request)
|
||||
assert.ok(
|
||||
system.includes(RECALL),
|
||||
`E1: recall must appear in the system prompt the adapter received. system prompt was ${JSON.stringify(system)}`,
|
||||
)
|
||||
|
||||
// ── E2 · negative: the recall is in NO message body ──────────────────
|
||||
const history = historyBlocks(request)
|
||||
const leaking = history.filter((entry) => entry.text.includes(RECALL))
|
||||
assert.deepEqual(
|
||||
leaking,
|
||||
[],
|
||||
`E2: recall must not appear in ANY non-system message; leaking messages: ${JSON.stringify(leaking)}`,
|
||||
)
|
||||
|
||||
// E2b · the loop must not duplicate it either: exactly one system message
|
||||
// may carry it, so a "promote but keep" bug cannot hide behind E1.
|
||||
const carriers = request.messages.filter((message) => bodyText(message).includes(RECALL))
|
||||
assert.equal(
|
||||
carriers.length,
|
||||
1,
|
||||
`E2b: exactly one message may carry the recall (a move, not a copy); got ${carriers.length}: ${JSON.stringify(
|
||||
carriers.map((message) => message.role),
|
||||
)}`,
|
||||
)
|
||||
assert.equal(carriers[0].role, 'system', `E2b: the sole carrier must be the system message; got ${carriers[0].role}`)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
457
test/loop.test.js
Normal file
457
test/loop.test.js
Normal file
@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Behavioural contract for the loop that promotes snapshot sections.
|
||||
*
|
||||
* The loop only earns its keep if a promoted recall block reaches the SYSTEM
|
||||
* prompt and does NOT reach history. These tests pin exactly that, plus the
|
||||
* cases where it must keep its hands off.
|
||||
*
|
||||
* P1 a pure snapshot carrier message moves into `assembly.sections`.
|
||||
* P2 the returned decision is a NEW object; the input is not mutated.
|
||||
* P3 the promoted section carries `interpolate: false` — free text with
|
||||
* `{{...}}` must not reach the strict interpolator.
|
||||
* P4a a carrier whose content text equals its sections IS promoted (the real
|
||||
* path: `lib/index.js:932` always attaches one text block); P4b the same
|
||||
* message with differing content is NOT; P4c proves the split is decided
|
||||
* by text equality, not by the presence of content.
|
||||
* P5 a non-snapshot message is NOT promoted.
|
||||
* P6 a name collision suffixes instead of overwriting or dropping.
|
||||
* P7 `reject` decisions pass through untouched.
|
||||
* P8 a decision with no `assembly` passes through untouched.
|
||||
* P9 everything that is not promoted survives in `messages`, in order.
|
||||
* P10 the class does not re-register the factory (no constructor / inject).
|
||||
*
|
||||
* @module dsh-memory/test/loop
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { HologramAgentLoop, promoteSnapshotSections } from '../lib/loop.js'
|
||||
|
||||
/** Build a user message shaped like a snapshot-style injector emits. */
|
||||
function snapshotMessage(text, kind = 'memory') {
|
||||
return {
|
||||
role: 'user',
|
||||
content: [],
|
||||
source: { kind, form: 'snapshot', sections: [{ name: kind, text }] },
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a plain user message with real content. */
|
||||
function plainMessage(text) {
|
||||
return { role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' } }
|
||||
}
|
||||
|
||||
/** A minimal decision carrying an assembly with one existing section. */
|
||||
function decisionWith(messages, sections = [{ name: 'base', text: 'base prompt' }]) {
|
||||
return { kind: 'enter', messages, assembly: { sections, variables: {}, tools: [] } }
|
||||
}
|
||||
|
||||
test('P1: a pure snapshot carrier is promoted into assembly.sections', () => {
|
||||
const decision = decisionWith([plainMessage('what did I decide?'), snapshotMessage('note alpha')])
|
||||
const out = promoteSnapshotSections(decision)
|
||||
|
||||
assert.equal(out.messages.length, 1)
|
||||
assert.deepEqual(out.messages[0].content, [{ type: 'text', text: 'what did I decide?' }])
|
||||
assert.equal(out.assembly.sections.length, 2)
|
||||
const promoted = out.assembly.sections[1]
|
||||
assert.equal(promoted.text, 'note alpha')
|
||||
assert.ok(promoted.name.startsWith('memory'))
|
||||
})
|
||||
|
||||
test('P2: the input decision is not mutated', () => {
|
||||
const original = decisionWith([snapshotMessage('note alpha')])
|
||||
const before = structuredClone(original)
|
||||
promoteSnapshotSections(original)
|
||||
assert.deepEqual(original, before)
|
||||
})
|
||||
|
||||
test('P3: the promoted section opts out of interpolation', () => {
|
||||
const out = promoteSnapshotSections(decisionWith([snapshotMessage('literal {{not-a-var}} here')]))
|
||||
assert.equal(out.assembly.sections[1].interpolate, false)
|
||||
})
|
||||
|
||||
test('P4a: a carrier whose content equals its sections IS promoted', () => {
|
||||
// The old P4 asserted the opposite for every message carrying content, on the
|
||||
// assumption that "content blocks present" means "the plugin wants the user to
|
||||
// see this". That assumption is FALSE for the only producer that matters here:
|
||||
// `lib/index.js:932` always injects exactly ONE text block whose text is
|
||||
// `sections[0].text` verbatim — content and sections are the same string. So
|
||||
// the old rule (`content non-empty ⇒ reject`) made `promoteSnapshotSections` a
|
||||
// permanent no-op on the real recall path and left recall in user/message
|
||||
// history, which is the bug this pair of tests exists to pin.
|
||||
const aligned = {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'recalled note' }],
|
||||
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: 'recalled note' }] },
|
||||
}
|
||||
const out = promoteSnapshotSections(decisionWith([aligned]))
|
||||
assert.equal(out.messages.length, 0, `content == sections must be promoted; got ${out.messages.length} message(s)`)
|
||||
assert.equal(out.assembly.sections.length, 2, `expected base + promoted; got ${out.assembly.sections.length}`)
|
||||
assert.equal(
|
||||
out.assembly.sections[1].text,
|
||||
'recalled note',
|
||||
`promoted text must be the content text unchanged; got ${JSON.stringify(out.assembly.sections[1].text)}`,
|
||||
)
|
||||
})
|
||||
|
||||
test('P4b: a carrier whose content differs from its sections is NOT promoted', () => {
|
||||
// The old P4 used 'visible' vs 'hidden' — content != sections — which is the
|
||||
// side that must keep its hands off. That is exactly why it was green on the
|
||||
// broken commit: it asserted the correct behaviour for the wrong reason and
|
||||
// never covered the case that actually occurs (`content == sections`). It is
|
||||
// kept here, correctly named, as one half of the pair.
|
||||
const drifted = {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'visible' }],
|
||||
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: 'hidden' }] },
|
||||
}
|
||||
const out = promoteSnapshotSections(decisionWith([drifted]))
|
||||
assert.equal(out.messages.length, 1, `content != sections must stay in history; got ${out.messages.length}`)
|
||||
assert.equal(out.assembly.sections.length, 1, `no section may be added; got ${out.assembly.sections.length}`)
|
||||
})
|
||||
|
||||
test('P4c: P4a and P4b are decided by TEXT, not by "has content at all"', () => {
|
||||
// One decision holding both carriers: if the implementation matched on the
|
||||
// mere presence of content, both would land on the same side and the
|
||||
// messages/sections counts below could not both hold.
|
||||
const aligned = {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'recalled note' }],
|
||||
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: 'recalled note' }] },
|
||||
}
|
||||
const drifted = {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'visible' }],
|
||||
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: 'hidden' }] },
|
||||
}
|
||||
const out = promoteSnapshotSections(decisionWith([aligned, drifted]))
|
||||
assert.deepEqual(out.messages, [drifted], 'only the mismatching carrier may survive')
|
||||
assert.equal(out.assembly.sections.length, 2, `only the matching carrier may be promoted; got ${out.assembly.sections.length}`)
|
||||
})
|
||||
|
||||
test('P5: a non-snapshot message is not promoted', () => {
|
||||
const runtime = {
|
||||
role: 'user',
|
||||
content: [],
|
||||
source: { kind: 'runtime-context', form: 'inline', sections: [{ name: 'x', text: 'y' }] },
|
||||
}
|
||||
const out = promoteSnapshotSections(decisionWith([runtime]))
|
||||
assert.equal(out.messages.length, 1)
|
||||
assert.equal(out.assembly.sections.length, 1)
|
||||
})
|
||||
|
||||
test('P6: a name collision is suffixed, never dropped', () => {
|
||||
const out = promoteSnapshotSections(
|
||||
decisionWith([snapshotMessage('note', 'base')], [{ name: 'base', text: 'base prompt' }]),
|
||||
)
|
||||
assert.equal(out.assembly.sections.length, 2)
|
||||
const names = out.assembly.sections.map((section) => section.name)
|
||||
assert.equal(new Set(names).size, names.length, 'section names must stay unique')
|
||||
assert.equal(out.assembly.sections[1].text, 'note')
|
||||
})
|
||||
|
||||
test('P7: a reject decision passes through untouched', () => {
|
||||
const reject = { kind: 'reject', reason: 'nope' }
|
||||
assert.equal(promoteSnapshotSections(reject), reject)
|
||||
})
|
||||
|
||||
test('P8: a decision without an assembly passes through untouched', () => {
|
||||
const bare = { kind: 'enter', messages: [snapshotMessage('note')] }
|
||||
assert.equal(promoteSnapshotSections(bare), bare)
|
||||
})
|
||||
|
||||
test('P9: non-promoted messages keep their order', () => {
|
||||
const out = promoteSnapshotSections(
|
||||
decisionWith([plainMessage('first'), snapshotMessage('note'), plainMessage('second')]),
|
||||
)
|
||||
assert.deepEqual(
|
||||
out.messages.map((message) => message.content[0].text),
|
||||
['first', 'second'],
|
||||
)
|
||||
})
|
||||
|
||||
test('P10: the subclass does not re-declare the factory or its inject list', () => {
|
||||
// A second `setFactory` registration throws at startup, and re-declaring
|
||||
// `inject` would pull a duplicate AgentLoop into the container. Both are
|
||||
// avoided by not defining a constructor or static inject at all.
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(HologramAgentLoop, 'inject'), false)
|
||||
const source = HologramAgentLoop.prototype.constructor.toString()
|
||||
assert.equal(source.includes('constructor'), false, 'subclass must not define a constructor')
|
||||
assert.equal(typeof HologramAgentLoop.prototype.prepare, 'function')
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// S1–S7 · the two canonical spellings, and the ones that must not be touched
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Why this block exists: every test above builds its snapshot carrier with
|
||||
// `content: []`. That is a shape NO real producer emits — `lib/index.js:932`
|
||||
// always attaches exactly one `{ type: 'text' }` block whose text is
|
||||
// `sections[0].text` verbatim. So the whole suite was green over a code path
|
||||
// that never runs in production, and `promoteSnapshotSections` was a no-op on
|
||||
// every real recall. The rule under test is three-way:
|
||||
//
|
||||
// content == sections (either joining convention) → PROMOTE, moving the text
|
||||
// content != sections → leave it in history
|
||||
//
|
||||
// The invariant is "move, don't rewrite": the promoted section text must be
|
||||
// *identical* to the text the model would otherwise have read out of the
|
||||
// message content, so these tests can tell a relocation from a silent rewrite.
|
||||
//
|
||||
// ★ Every negative case below carries a POSITIVE CONTROL in the same decision:
|
||||
// a carrier of the same shape that *must* be promoted. Without the control a
|
||||
// negative assertion is satisfied by the old "content non-empty ⇒ reject" rule
|
||||
// alone, so it would be green on the broken commit and prove nothing. With it,
|
||||
// the assertion fails unless the implementation is comparing the two texts.
|
||||
|
||||
/** A user message whose content and sections are the SAME single text. */
|
||||
function carrier(text, kind = 'memory') {
|
||||
return {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind, form: 'snapshot', sections: [{ name: kind, text }] },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A carrier whose N sections are rendered into ONE content block.
|
||||
*
|
||||
* The name states the shape: ONE text block. It models the two joining
|
||||
* conventions ('\n\n' and '') on that single block, which is what
|
||||
* `lib/index.js:932` actually emits.
|
||||
*
|
||||
* ★ This helper was previously called `multiCarrier` while producing a single
|
||||
* block, and that name is exactly what let the V2 bug (`if (blocks.length > 1)
|
||||
* return null`) pass untouched: S2a/S2b read as multi-block coverage while
|
||||
* exercising none of it. A helper whose name overstates its shape is worse than
|
||||
* a missing test, because it looks like coverage. Real multi-block carriers come
|
||||
* from `trueMultiCarrier` below and are exercised by S2c/S2d.
|
||||
* @param {string[]} texts one entry per section
|
||||
* @param {'glued'|'spaced'} convention how the sections were concatenated
|
||||
*/
|
||||
function singleBlockCarrier(texts, convention = 'spaced', kind = 'memory') {
|
||||
const joined = convention === 'glued' ? texts.join('') : texts.join('\n\n')
|
||||
return {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: joined }],
|
||||
source: { kind, form: 'snapshot', sections: texts.map((text, i) => ({ name: `${kind}-${i}`, text })) },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 产出:N 个 text block,每个 block 一个 section 的文本(rendered = texts.join(''))。
|
||||
* 这不是本插件产出的形状,是"其它 producer 逐块发文本"的形状;只用来证明多块路径有牙。
|
||||
* 注意:真多块时 rendered 恒为 glued 拼法,没有 spaced 变体 —— spaced 只由单块承载。
|
||||
* @param {string[]} texts one entry per block AND per section
|
||||
* @param {string} kind source kind
|
||||
*/
|
||||
function trueMultiCarrier(texts, kind = 'memory') {
|
||||
return {
|
||||
role: 'user',
|
||||
content: texts.map((text) => ({ type: 'text', text })),
|
||||
source: { kind, form: 'snapshot', sections: texts.map((text, i) => ({ name: `${kind}-${i}`, text })) },
|
||||
}
|
||||
}
|
||||
|
||||
/** Readable "expected vs actual" rendering for the assertion messages. */
|
||||
const show = (value) => JSON.stringify(value)
|
||||
|
||||
test('S1: single block == single section is promoted, text byte-identical', () => {
|
||||
const out = promoteSnapshotSections(decisionWith([plainMessage('what did I decide?'), carrier('note alpha')]))
|
||||
assert.equal(
|
||||
out.messages.length,
|
||||
1,
|
||||
`the carrier must leave messages (expected 1 remaining, got ${out.messages.length})`,
|
||||
)
|
||||
assert.equal(
|
||||
out.assembly.sections.length,
|
||||
2,
|
||||
`expected base + promoted section = 2, got ${out.assembly.sections.length}`,
|
||||
)
|
||||
const promoted = out.assembly.sections[1]
|
||||
assert.equal(
|
||||
promoted.text,
|
||||
'note alpha',
|
||||
`promoted text must be the carrier text unchanged; expected ${show('note alpha')}, got ${show(promoted.text)}`,
|
||||
)
|
||||
})
|
||||
|
||||
test('S2a: single content block carrying N sections ("\\n\\n" convention) is promoted', () => {
|
||||
const out = promoteSnapshotSections(decisionWith([singleBlockCarrier(['a', 'b'], 'spaced')]))
|
||||
assert.equal(
|
||||
out.messages.length,
|
||||
0,
|
||||
`the spaced carrier must be promoted (expected 0 messages, got ${out.messages.length})`,
|
||||
)
|
||||
assert.equal(
|
||||
out.assembly.sections.length,
|
||||
2,
|
||||
`expected base + promoted = 2 sections, got ${out.assembly.sections.length}`,
|
||||
)
|
||||
assert.equal(
|
||||
out.assembly.sections[1].text,
|
||||
'a\n\nb',
|
||||
`promoted text must equal the content text exactly (move, not rewrite); expected ${show('a\n\nb')}, got ${show(out.assembly.sections[1].text)}`,
|
||||
)
|
||||
})
|
||||
|
||||
test('S2b: single content block carrying N sections ("" glued convention) is promoted', () => {
|
||||
const out = promoteSnapshotSections(decisionWith([singleBlockCarrier(['a', 'b'], 'glued')]))
|
||||
assert.equal(
|
||||
out.messages.length,
|
||||
0,
|
||||
`the glued carrier must be promoted (expected 0 messages, got ${out.messages.length})`,
|
||||
)
|
||||
assert.equal(
|
||||
out.assembly.sections.length,
|
||||
2,
|
||||
`expected base + promoted = 2 sections, got ${out.assembly.sections.length}`,
|
||||
)
|
||||
assert.equal(
|
||||
out.assembly.sections[1].text,
|
||||
'ab',
|
||||
`promoted text must equal the content text exactly; expected ${show('ab')}, got ${show(out.assembly.sections[1].text)}`,
|
||||
)
|
||||
})
|
||||
|
||||
test('S2c: two content blocks == two sections (glued) is promoted', () => {
|
||||
const msg = trueMultiCarrier(['a', 'b']) // 2 blocks, rendered='ab'
|
||||
const out = promoteSnapshotSections(decisionWith([msg]))
|
||||
assert.equal(out.messages.length, 0, `two blocks == two sections must be promoted; got ${out.messages.length} message(s)`)
|
||||
assert.equal(out.assembly.sections[1].text, 'ab', `promoted text must be the rendered content; got ${JSON.stringify(out.assembly.sections[1].text)}`)
|
||||
})
|
||||
|
||||
test('S2d: two content blocks != sections is NOT promoted (control: the match is)', () => {
|
||||
// rendered = 'ab' (glued), while the single section says 'a\n\nb' — neither
|
||||
// canonical join matches, so this must stay in history. The control beside it
|
||||
// is the same two blocks with matching sections, which must be promoted.
|
||||
const drifted = {
|
||||
...trueMultiCarrier(['a', 'b']),
|
||||
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'm0', text: 'a\n\nb' }] },
|
||||
}
|
||||
const control = trueMultiCarrier(['a', 'b'])
|
||||
const out = promoteSnapshotSections(decisionWith([control, drifted]))
|
||||
assert.equal(out.messages.length, 1, `only the drifted one may stay; got ${out.messages.length}`)
|
||||
assert.equal(out.messages[0], drifted, 'the surviving message must be the drifted one')
|
||||
})
|
||||
|
||||
test('S3: single content block carrying N sections != those sections is NOT promoted (control: the match is)', () => {
|
||||
// Sections say "a"+"b", the content block says "zzz": editing the text would
|
||||
// not be a move, so this one must stay in history — while the matching
|
||||
// carrier beside it must go. The two must be told apart by CONTENT, not by
|
||||
// "does it carry content at all".
|
||||
const drifted = {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'zzz' }],
|
||||
source: {
|
||||
kind: 'memory',
|
||||
form: 'snapshot',
|
||||
sections: [{ name: 'memory-0', text: 'a' }, { name: 'memory-1', text: 'b' }],
|
||||
},
|
||||
}
|
||||
const control = singleBlockCarrier(['a', 'b'], 'spaced')
|
||||
const out = promoteSnapshotSections(decisionWith([control, drifted]))
|
||||
assert.equal(
|
||||
out.messages.length,
|
||||
1,
|
||||
`only the drifted carrier may stay (expected 1 message, got ${out.messages.length})`,
|
||||
)
|
||||
assert.equal(out.messages[0], drifted, 'the surviving message must be the drifted carrier itself')
|
||||
assert.equal(
|
||||
out.assembly.sections.length,
|
||||
2,
|
||||
`only the control may be promoted (expected 2 sections, got ${out.assembly.sections.length})`,
|
||||
)
|
||||
assert.equal(
|
||||
out.assembly.sections[1].text,
|
||||
'a\n\nb',
|
||||
`the promoted text must come from the control; expected ${show('a\n\nb')}, got ${show(out.assembly.sections[1].text)}`,
|
||||
)
|
||||
})
|
||||
|
||||
test('S4: single block != single section is NOT promoted (control: the match is)', () => {
|
||||
const drifted = {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'content says this' }],
|
||||
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: 'sections say that' }] },
|
||||
}
|
||||
const control = carrier('note alpha')
|
||||
const out = promoteSnapshotSections(decisionWith([control, drifted]))
|
||||
assert.equal(out.messages.length, 1, `only the drifted carrier may stay; got ${out.messages.length} messages`)
|
||||
assert.equal(out.messages[0], drifted, 'the surviving message must be the drifted carrier itself')
|
||||
assert.equal(out.assembly.sections.length, 2, `only the control may be promoted; got ${out.assembly.sections.length} sections`)
|
||||
assert.equal(
|
||||
out.assembly.sections[1].text,
|
||||
'note alpha',
|
||||
`promoted text must be the control's; expected ${show('note alpha')}, got ${show(out.assembly.sections[1].text)}`,
|
||||
)
|
||||
})
|
||||
|
||||
test('S5: empty content (legacy carrier) is promoted — regression, with its real-world twin', () => {
|
||||
// Two carriers: the legacy `content: []` shape (which the old code did handle)
|
||||
// and the shape every real producer emits (which it did not). Both must be
|
||||
// promoted, so this doubles as the regression pin for the old path AND as the
|
||||
// minimal reproduction of the production no-op.
|
||||
const out = promoteSnapshotSections(decisionWith([snapshotMessage('note alpha'), carrier('note beta')]))
|
||||
assert.equal(
|
||||
out.messages.length,
|
||||
0,
|
||||
`both carriers must be promoted (expected 0 messages, got ${out.messages.length})`,
|
||||
)
|
||||
assert.deepEqual(
|
||||
out.assembly.sections.map((section) => section.text),
|
||||
['base prompt', 'note alpha', 'note beta'],
|
||||
`expected the legacy text first, then the real-world text; got ${show(out.assembly.sections.map((s) => s.text))}`,
|
||||
)
|
||||
})
|
||||
|
||||
test('S6: runtime-context shape (prefix in content, absent from sections) is NOT promoted', () => {
|
||||
// The stock loop prepends a fixed sentence to the content and keeps `sections`
|
||||
// as the raw contributions, so the two can never be equal. Promoting it would
|
||||
// delete that preamble, so it must pass through — the control carrier in the
|
||||
// same decision proves the comparison is on text, not on "has content".
|
||||
const runtime = {
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\ncwd /tmp',
|
||||
},
|
||||
],
|
||||
source: { kind: 'runtime-context', form: 'snapshot', sections: [{ name: 'runtime-context', text: 'cwd /tmp' }] },
|
||||
}
|
||||
const control = carrier('cwd /tmp', 'runtime-context')
|
||||
const out = promoteSnapshotSections(decisionWith([control, runtime]))
|
||||
assert.equal(out.messages.length, 1, `runtime-context must stay in history; got ${out.messages.length} messages`)
|
||||
assert.equal(out.messages[0], runtime, 'the surviving message must be the runtime-context snapshot itself')
|
||||
assert.equal(out.assembly.sections.length, 2, `only the control may be promoted; got ${out.assembly.sections.length} sections`)
|
||||
assert.equal(
|
||||
out.assembly.sections[1].text,
|
||||
'cwd /tmp',
|
||||
`promoted text must be the control's; expected ${show('cwd /tmp')}, got ${show(out.assembly.sections[1].text)}`,
|
||||
)
|
||||
})
|
||||
|
||||
test('S7: a non-text block means NOT promoted (control: the text-only one is)', () => {
|
||||
const imageCarrier = {
|
||||
role: 'user',
|
||||
content: [{ type: 'image', text: 'note alpha' }],
|
||||
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: 'note alpha' }] },
|
||||
}
|
||||
const badText = {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 42 }],
|
||||
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: '42' }] },
|
||||
}
|
||||
const control = carrier('note alpha')
|
||||
const out = promoteSnapshotSections(decisionWith([control, imageCarrier, badText]))
|
||||
assert.equal(out.messages.length, 2, `only the text-only control may be promoted; got ${out.messages.length} messages`)
|
||||
assert.equal(out.messages[0], imageCarrier, 'the image carrier must survive')
|
||||
assert.equal(out.messages[1], badText, 'the non-string-text carrier must survive')
|
||||
assert.equal(out.assembly.sections.length, 2, `only the control may be promoted; got ${out.assembly.sections.length} sections`)
|
||||
assert.equal(
|
||||
out.assembly.sections[1].text,
|
||||
'note alpha',
|
||||
`promoted text must be the control's; expected ${show('note alpha')}, got ${show(out.assembly.sections[1].text)}`,
|
||||
)
|
||||
})
|
||||
836
test/memory-log.test.js
Normal file
836
test/memory-log.test.js
Normal file
@ -0,0 +1,836 @@
|
||||
/**
|
||||
* Observability contract for the recall log.
|
||||
*
|
||||
* The plugin shipped with zero `console.*` and zero `ctx.logger` calls, so a
|
||||
* production recall failure left no trace anywhere: `degraded` is a return value
|
||||
* the caller sees once, and the injected text only shows up if the model quotes
|
||||
* it. These tests pin the replacement channel.
|
||||
*
|
||||
* What is pinned here, and why each assertion exists:
|
||||
*
|
||||
* L1 `debugLog: false` (the default) is quiet on an ordinary turn, and still
|
||||
* carries every fact the operator needs when it does speak.
|
||||
* L2 `debugLog: true` emits exactly ONE line covering recall / budget /
|
||||
* degradation / injection.
|
||||
* L3 the query TEXT is never logged — only its length.
|
||||
* L4 a real fault (`embed_error`) speaks even with `debugLog` off, at `warn`.
|
||||
* L5 a zero-injection turn is `debug`, not `info` — the commonest case must
|
||||
* cost nothing at normal verbosity.
|
||||
* L6 logging changes no behaviour: the returned text and `lastRecall` are
|
||||
* identical with the summary on and off.
|
||||
* L7 nothing is ever written to stdout (headless prints the ANSWER there) and
|
||||
* the console fallback goes to `console.warn`, not `console.log`.
|
||||
* L10 `ORDINARY_DEGRADATIONS` actually gates the level: the two healthy modes
|
||||
* (`hybrid`, `no_vector_space`) are SILENT with `debugLog` off, while a
|
||||
* real fault (`embed_error`) is a single `warn`. L10†/L11† mutate the set to
|
||||
* prove those two assertions bite.
|
||||
* L11 a fused recall whose candidates are ALL refused by `minScore` still
|
||||
* reports `hybrid` with zero hits, and is still silent. `no_vector_space`
|
||||
* means "there was nothing to score", never "everything was filtered" —
|
||||
* collapsing the two would hide a threshold set too high.
|
||||
*
|
||||
* @module dsh-memory/test/memory-log
|
||||
*/
|
||||
|
||||
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 { Context } from '@deepseek-ai/cordis'
|
||||
import { Logger } from '@deepseek-ai/cordis'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import { ToolRuntime } from '@deepseek-ai/dsh-tools'
|
||||
import { apply as applyMemory, StableMem } from '../lib/index.js'
|
||||
import { recallBlocksHybrid, segmentRecallBudget } from '../lib/recall.js'
|
||||
import { BlockStore } from '../lib/store.js'
|
||||
|
||||
/**
|
||||
* Build a real cordis app + plugin instance, capturing every log record.
|
||||
*
|
||||
* `levels: { default: 3 }` is DEBUG: cordis skips a record when
|
||||
* `(exporter.levels[name] ?? default ?? logger.level ?? INFO) < level`
|
||||
* (cordis/lib/index.js:470-472), so the default would silently drop `debug` and
|
||||
* L5 could never observe its own subject.
|
||||
*
|
||||
* @param {object} config plugin config
|
||||
*/
|
||||
async function makeMemWith(apply, config = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'mlog-'))
|
||||
const ctx = new Context()
|
||||
new SystemPrompt(ctx, {})
|
||||
new ToolRuntime(ctx, {})
|
||||
const records = []
|
||||
ctx.logger.exporter({
|
||||
levels: { default: 3 },
|
||||
export: (message) => records.push(message),
|
||||
})
|
||||
const mem = apply(ctx, { path: join(dir, 'm.db'), ...config })
|
||||
return {
|
||||
ctx,
|
||||
mem,
|
||||
records,
|
||||
/**
|
||||
* Only this plugin's lines, rendered the way the logger actually prints them.
|
||||
*
|
||||
* `Logger.format` rather than a plain `args.join(' ')`: every call site emits
|
||||
* `log(level)('%s', line)` so the payload cannot be reformatted (see `emit`)
|
||||
* — a naive join would keep the literal `%s` prefix and hide a regression
|
||||
* where the payload stopped being a single argument.
|
||||
*/
|
||||
lines: () =>
|
||||
records
|
||||
.filter((record) => record.name === 'dsh-memory')
|
||||
.map((record) => ({ type: record.type, text: Logger.format({}, record) })),
|
||||
async done() {
|
||||
await ctx.fiber.dispose()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same app, built by the module under test.
|
||||
*
|
||||
* Parameterised by `apply` so the ORDINARY_DEGRADATIONS mutants below can run the
|
||||
* IDENTICAL criterion body against a mutated `lib/index.js`. A criterion that is
|
||||
* copied for the mutant can drift from the real one; a criterion that is shared
|
||||
* cannot.
|
||||
*
|
||||
* @param {object} [config] plugin config
|
||||
*/
|
||||
async function makeMem(config = {}) {
|
||||
return makeMemWith(applyMemory, config)
|
||||
}
|
||||
|
||||
/** A one-message human batch. */
|
||||
function ask(text) {
|
||||
return [createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })]
|
||||
}
|
||||
|
||||
/** An embedder that fails the way a dead provider does. */
|
||||
const failingEmbedder = {
|
||||
async embedText() {
|
||||
throw new Error('read ECONNREFUSED 127.0.0.1:8081')
|
||||
},
|
||||
info: () => ({ fingerprint: 'fp-log', dimension: 3 }),
|
||||
}
|
||||
|
||||
// ── L1: default config is quiet, and stays quiet for the commonest case ───
|
||||
test('L1: debugLog defaults to false and an ordinary hitless turn logs nothing', async () => {
|
||||
const { mem, lines, done } = await makeMem()
|
||||
try {
|
||||
assert.equal(mem.debugLog, false, 'the default must be false — the log is opt-in')
|
||||
// No embedder is the shipping default; recall degrades to `no_embedder`,
|
||||
// which is a standing configuration fact, not an event (same judgement W11e
|
||||
// made for the model-facing notice).
|
||||
await mem.commit({ text: '某条无关记忆' })
|
||||
for (const turn of ['今天天气怎么样', '你好', '好的']) {
|
||||
await mem.contextForStep(ask(turn))
|
||||
}
|
||||
assert.deepEqual(lines(), [], `the default must be silent; got ${JSON.stringify(lines())}`)
|
||||
} finally {
|
||||
await done()
|
||||
}
|
||||
})
|
||||
|
||||
// ── L2/L3: opt in, then all four fact groups appear in ONE line ───────────
|
||||
test('L2: debugLog=true emits one line covering recall, budget, degradation and injection', async () => {
|
||||
const { mem, lines, done } = await makeMem({ debugLog: true, topK: 3, budgetTokens: 500 })
|
||||
try {
|
||||
assert.equal(mem.debugLog, true, 'config.debugLog must reach the service')
|
||||
await mem.commit({ text: '值班室分机号 4324,值班人 老周' })
|
||||
|
||||
const question = '值班室分机号是多少'
|
||||
const text = await mem.contextForStep(ask(question))
|
||||
assert.notEqual(text, '', 'precondition: the block must be recalled')
|
||||
|
||||
const emitted = lines()
|
||||
assert.equal(emitted.length, 1, `exactly one line per recall turn; got ${JSON.stringify(emitted)}`)
|
||||
const [record] = emitted
|
||||
assert.equal(record.type, 'info', 'a turn that injected something is an info line')
|
||||
|
||||
// recall: length only, never the text.
|
||||
assert.match(record.text, /recall query=9ch/, `query length in code points; got: ${record.text}`)
|
||||
assert.ok(
|
||||
!record.text.includes(question),
|
||||
`the query TEXT must never be logged; got: ${record.text}`,
|
||||
)
|
||||
assert.match(record.text, /hits=[1-9]\d*/, 'hit count')
|
||||
assert.match(record.text, /topK=3/, 'topK actually used')
|
||||
assert.match(record.text, /zeroHit=no/, 'the 0-hit flag must be stated, not inferred')
|
||||
|
||||
// budget: which cap applied and what it retained.
|
||||
assert.match(record.text, /budget=500 /, 'the explicit budget wins')
|
||||
assert.match(record.text, /source=explicit/, 'source must name the rule that produced the budget')
|
||||
assert.match(record.text, /kept=1\b/, 'retained count after segmenting')
|
||||
assert.match(record.text, /singleHitOverBudget=(yes|no)/, 'the over-budget single-hit case is reported')
|
||||
|
||||
// degradation: the empty case is reported as healthy, explicitly.
|
||||
assert.match(record.text, /degraded=no_embedder/, 'the mode is named, not omitted')
|
||||
assert.match(record.text, /skipped=0 skippedDim=0/, 'skip counters')
|
||||
// D2: the three downstream counters must be PRINTED, not merely computed.
|
||||
// A counter nobody can read is indistinguishable from no counter at all.
|
||||
assert.match(record.text, /skippedScore=0 skippedNoEvidence=0 skippedUnscoreable=0 truncated=0/, 'downstream drop counters')
|
||||
assert.match(record.text, /err=-/, 'no error digest when there is no error')
|
||||
|
||||
// injection: the exact string length that reached the model.
|
||||
assert.match(record.text, new RegExp(`inject=${[...text].length}ch`), 'injected length')
|
||||
} finally {
|
||||
await done()
|
||||
}
|
||||
})
|
||||
|
||||
test('L3: the budget source is named for each precedence rule', async () => {
|
||||
// explicit > host window > fallback, read straight off the summary line.
|
||||
const explicit = await makeMem({ debugLog: true, budgetTokens: 1234 })
|
||||
try {
|
||||
await explicit.mem.commit({ text: '值班室分机号 4324' })
|
||||
await explicit.mem.contextForStep(ask('值班室分机号是多少'))
|
||||
assert.match(explicit.lines()[0].text, /budget=1234 source=explicit/)
|
||||
} finally {
|
||||
await explicit.done()
|
||||
}
|
||||
|
||||
const host = await makeMem({ debugLog: true })
|
||||
try {
|
||||
await host.mem.commit({ text: '值班室分机号 4324' })
|
||||
// A host window of 100 tokens → 80% = 80 token budget.
|
||||
await host.mem.contextForStep(ask('值班室分机号是多少'), undefined, { contextWindow: 100 })
|
||||
assert.match(host.lines()[0].text, /budget=80 source=host/)
|
||||
} finally {
|
||||
await host.done()
|
||||
}
|
||||
|
||||
const fallback = await makeMem({ debugLog: true })
|
||||
try {
|
||||
await fallback.mem.commit({ text: '值班室分机号 4324' })
|
||||
await fallback.mem.contextForStep(ask('值班室分机号是多少'))
|
||||
assert.match(fallback.lines()[0].text, /budget=4096 source=fallback/)
|
||||
} finally {
|
||||
await fallback.done()
|
||||
}
|
||||
})
|
||||
|
||||
// ── L2b: non-zero drop counters reach BOTH operator surfaces ──────────────
|
||||
//
|
||||
// L2 only proves the fields are printed when everything is zero. A counter that
|
||||
// is correct in the returned array but lost on the way to the log is still
|
||||
// invisible — and `segmentRecallBudget` slices that array, which is exactly where
|
||||
// non-enumerable properties normally disappear.
|
||||
//
|
||||
// Fixture: three blocks on separate axes, a query mostly on axis 0. Cosines are
|
||||
// ~0.95 (passes), ~0.30 (gated by the 0.5 threshold) and 0 (nothing to score),
|
||||
// so the three destinations are all populated at once.
|
||||
test('L2b: non-zero drop counters appear in the log and in last_recall', async () => {
|
||||
const DIM = 4
|
||||
const unit = (v) => { const n = Math.hypot(...v); return n === 0 ? v : v.map((x) => x / n) }
|
||||
const axis = (i) => unit(Array.from({ length: DIM }, (_, k) => (k === i ? 1 : 0)))
|
||||
const QUERY = unit([0.95, 0.30, 0, 0])
|
||||
const byText = new Map([
|
||||
['值班室的分机号是 4324', axis(0)],
|
||||
['食堂在东楼二层', axis(1)],
|
||||
['叉车的额定载重是两吨', axis(2)],
|
||||
])
|
||||
const embedder = {
|
||||
async embedText(text) {
|
||||
if (text === '分机号是多少') return QUERY
|
||||
return byText.get(text) ?? new Array(DIM).fill(0)
|
||||
},
|
||||
info: () => ({ fingerprint: 'd2:nonzero', dimension: DIM }),
|
||||
}
|
||||
|
||||
const { mem, lines, done } = await makeMemWith(applyMemory, { debugLog: true, embedder })
|
||||
try {
|
||||
for (const text of byText.keys()) await mem.commit({ text })
|
||||
await mem.contextForStep(ask('分机号是多少'))
|
||||
|
||||
const emitted = lines()
|
||||
assert.equal(emitted.length, 1, `exactly one line per recall turn; got ${JSON.stringify(emitted)}`)
|
||||
assert.match(
|
||||
emitted[0].text,
|
||||
/skippedScore=1 skippedNoEvidence=1 skippedUnscoreable=0 truncated=0/,
|
||||
`D2: the counters must survive segmenting and reach the log; got ${emitted[0].text}`,
|
||||
)
|
||||
assert.equal(mem.lastRecall.skipped_score, 1, 'and the durable surface must agree')
|
||||
assert.equal(mem.lastRecall.skipped_no_evidence, 1, 'both silent-drop kinds reported separately')
|
||||
assert.equal(mem.lastRecall.truncated, 0, 'nothing was cut by topK here')
|
||||
} finally {
|
||||
await done()
|
||||
}
|
||||
})
|
||||
|
||||
// ── L4: a real fault speaks with the log switched off ─────────────────────
|
||||
test('L4: an embed_error is warned about even when debugLog is false', async () => {
|
||||
const { mem, lines, done } = await makeMem({ embedder: failingEmbedder })
|
||||
try {
|
||||
assert.equal(mem.debugLog, false)
|
||||
await mem.commit({ text: '值班室分机号 4324' })
|
||||
// `commit` also embeds, so clear the records that produced to keep the
|
||||
// assertion about the RECALL line unambiguous.
|
||||
lines().length = 0
|
||||
await mem.contextForStep(ask('值班室分机号是多少'))
|
||||
|
||||
const recalled = lines().filter((line) => line.text.startsWith('recall '))
|
||||
assert.equal(recalled.length, 1, `a fault must produce exactly one recall line; got ${JSON.stringify(recalled)}`)
|
||||
assert.equal(recalled[0].type, 'warn', 'a failed search is a warn, not an info/debug')
|
||||
assert.match(recalled[0].text, /degraded=embed_error/)
|
||||
assert.match(
|
||||
recalled[0].text,
|
||||
/err=read ECONNREFUSED 127\.0\.0\.1:8081/,
|
||||
`the error digest must name the cause; got: ${recalled[0].text}`,
|
||||
)
|
||||
} finally {
|
||||
await done()
|
||||
}
|
||||
})
|
||||
|
||||
test('L4b: a long provider error is flattened and truncated, never a multi-line log', async () => {
|
||||
const verbose = {
|
||||
async embedText() {
|
||||
throw new Error(`connection refused\n at socket.js:1\n${'x'.repeat(400)}`)
|
||||
},
|
||||
info: () => ({ fingerprint: 'fp-log2', dimension: 3 }),
|
||||
}
|
||||
const { mem, lines, done } = await makeMem({ embedder: verbose })
|
||||
try {
|
||||
await mem.contextForStep(ask('值班室分机号是多少'))
|
||||
const recalled = lines().filter((line) => line.text.startsWith('recall '))
|
||||
assert.equal(recalled.length, 1)
|
||||
assert.ok(!recalled[0].text.includes('\n'), 'a log line must be one line')
|
||||
assert.ok(recalled[0].text.includes('...'), 'the digest must be truncated')
|
||||
assert.ok(
|
||||
recalled[0].text.length < 400,
|
||||
`the digest must be bounded; got ${recalled[0].text.length} chars`,
|
||||
)
|
||||
} finally {
|
||||
await done()
|
||||
}
|
||||
})
|
||||
|
||||
// ── L5: the commonest case is `debug`, not `info` ────────────────────────
|
||||
test('L5: a zero-injection turn logs exactly one DEBUG line, not info', async () => {
|
||||
const { mem, lines, done } = await makeMem({ debugLog: true })
|
||||
try {
|
||||
await mem.commit({ text: '某条无关记忆' })
|
||||
const text = await mem.contextForStep(ask('今天天气怎么样'))
|
||||
assert.equal(text, '', 'precondition: nothing is injected')
|
||||
|
||||
const emitted = lines()
|
||||
assert.equal(emitted.length, 1, `still exactly one line; got ${JSON.stringify(emitted)}`)
|
||||
assert.equal(emitted[0].type, 'debug', 'zero injection must not be an info line')
|
||||
assert.match(emitted[0].text, /zeroHit=yes/, 'the 0-hit case must say so')
|
||||
assert.match(emitted[0].text, /inject=0ch/)
|
||||
} finally {
|
||||
await done()
|
||||
}
|
||||
})
|
||||
|
||||
test('L5b: a batch with no human prompt logs one debug line and recalls nothing', async () => {
|
||||
const { mem, lines, done } = await makeMem({ debugLog: true })
|
||||
try {
|
||||
// This is the shape that once made every real turn recall the skill catalog,
|
||||
// so "there was no human prompt" is worth a line of its own.
|
||||
const injected = await mem.contextForStep([
|
||||
createUserMessage({
|
||||
content: [{ type: 'text', text: '[Skills]\n- alpha\n- beta' }],
|
||||
source: { kind: 'skill-catalog', form: 'snapshot' },
|
||||
}),
|
||||
])
|
||||
assert.equal(injected, '', 'an injected snapshot is not a recall query')
|
||||
const emitted = lines()
|
||||
assert.equal(emitted.length, 1)
|
||||
assert.equal(emitted[0].type, 'debug')
|
||||
assert.match(emitted[0].text, /query=0ch/)
|
||||
assert.match(emitted[0].text, /skipped=no-human-prompt/)
|
||||
} finally {
|
||||
await done()
|
||||
}
|
||||
})
|
||||
|
||||
// ── L6: observability must be inert ──────────────────────────────────────
|
||||
test('L6: turning the log on changes neither the injected text nor lastRecall', async () => {
|
||||
const run = async (debugLog) => {
|
||||
const app = await makeMem({ debugLog })
|
||||
try {
|
||||
await app.mem.commit({ text: '值班室分机号 4324,值班人 老周' })
|
||||
const text = await app.mem.contextForStep(ask('值班室分机号是多少'), undefined, {
|
||||
contextWindow: 2000,
|
||||
})
|
||||
const { at, ...lastRecall } = app.mem.stats().last_recall ?? {}
|
||||
void at
|
||||
return { text, lastRecall }
|
||||
} finally {
|
||||
await app.done()
|
||||
}
|
||||
}
|
||||
|
||||
const quiet = await run(false)
|
||||
const loud = await run(true)
|
||||
assert.equal(loud.text, quiet.text, 'the injected text must be byte-identical')
|
||||
assert.notEqual(quiet.text, '', 'precondition: something was injected')
|
||||
assert.deepEqual(loud.lastRecall, quiet.lastRecall, 'the stats surface must be unchanged')
|
||||
})
|
||||
|
||||
// ── L7: stdout stays clean; the fallback shim goes to warn ───────────────
|
||||
test('L7: logging never touches stdout, and the console fallback uses console.warn', async () => {
|
||||
const stdout = []
|
||||
const warned = []
|
||||
const errored = []
|
||||
const originals = {
|
||||
log: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
}
|
||||
console.log = (...args) => stdout.push(args)
|
||||
console.warn = (...args) => warned.push(args)
|
||||
console.error = (...args) => errored.push(args)
|
||||
|
||||
const { mem, done } = await makeMem({ debugLog: true })
|
||||
let fallbackCtx = null
|
||||
let fallbackStore = null
|
||||
try {
|
||||
await mem.commit({ text: '值班室分机号 4324' })
|
||||
await mem.contextForStep(ask('值班室分机号是多少'))
|
||||
assert.deepEqual(
|
||||
stdout,
|
||||
[],
|
||||
`the real logger path must never write stdout (headless prints the ANSWER there); got ${JSON.stringify(stdout)}`,
|
||||
)
|
||||
|
||||
// Now the fallback: a context whose logger service cannot produce a facade.
|
||||
// `resolveLogger` must degrade to console.warn rather than throw.
|
||||
fallbackCtx = new Context()
|
||||
fallbackCtx.logger = () => {
|
||||
throw new Error('no logger service')
|
||||
}
|
||||
fallbackStore = new BlockStore({ path: ':memory:' }).open()
|
||||
const fallback = new StableMem(fallbackCtx, { store: fallbackStore, debugLog: true })
|
||||
fallback.log.warn('%s', 'shim warn')
|
||||
fallback.log.debug('%s', 'shim debug')
|
||||
fallback.log.error('%s', 'shim error')
|
||||
|
||||
assert.deepEqual(stdout, [], `stdout must stay clean; got ${JSON.stringify(stdout)}`)
|
||||
assert.deepEqual(
|
||||
warned.map((args) => args.join(' ')),
|
||||
['shim warn', 'shim debug'],
|
||||
'the fallback routes both debug and info to console.warn',
|
||||
)
|
||||
assert.deepEqual(errored.map((args) => args.join(' ')), ['shim error'])
|
||||
} finally {
|
||||
console.log = originals.log
|
||||
console.warn = originals.warn
|
||||
console.error = originals.error
|
||||
fallbackStore?.close()
|
||||
await fallbackCtx?.fiber.dispose()
|
||||
await done()
|
||||
}
|
||||
})
|
||||
|
||||
// ── L4†: a mutation, not a self-test ─────────────────────────────────────
|
||||
//
|
||||
// "A guard that cannot fail is not a guard" — the same rule W11d†/W11e† follow in
|
||||
// phase-B-H. This one patches `lib/index.js` on a COPY, imports it fresh, and
|
||||
// asserts the behaviour flips. If the anchor moves, the split-count assertion
|
||||
// fails loudly instead of the test silently passing against unmutated source.
|
||||
test('L4†: silencing the fault branch turns L4 red', async () => {
|
||||
const { readFileSync, writeFileSync, mkdtempSync, rmSync, cpSync } = await import('node:fs')
|
||||
const { tmpdir } = await import('node:os')
|
||||
const { join } = await import('node:path')
|
||||
const { fileURLToPath, pathToFileURL } = await import('node:url')
|
||||
|
||||
const libDir = fileURLToPath(new URL('../lib/', import.meta.url))
|
||||
const pkgRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
const dir = mkdtempSync(join(tmpdir(), 'mlog-mut-'))
|
||||
try {
|
||||
cpSync(libDir, join(dir, 'lib'), { recursive: true })
|
||||
cpSync(join(pkgRoot, 'package.json'), join(dir, 'package.json'))
|
||||
try {
|
||||
cpSync(join(pkgRoot, 'node_modules'), join(dir, 'node_modules'), {
|
||||
recursive: true,
|
||||
dereference: false,
|
||||
})
|
||||
} catch {
|
||||
// Poetry-free install: the body below will fail loudly, which is correct.
|
||||
}
|
||||
|
||||
const target = join(dir, 'lib', 'index.js')
|
||||
const source = readFileSync(target, 'utf8')
|
||||
const anchor = ' if (this.debugLog !== true && !fault) return'
|
||||
assert.equal(
|
||||
source.split(anchor).length - 1,
|
||||
1,
|
||||
'L4† anchor must occur exactly once — did the gating line move?',
|
||||
)
|
||||
writeFileSync(target, source.replace(anchor, ' if (this.debugLog !== true) return // MUTATED'))
|
||||
|
||||
const mod = await import(pathToFileURL(target).href)
|
||||
const ctx = new Context()
|
||||
new SystemPrompt(ctx, {})
|
||||
new ToolRuntime(ctx, {})
|
||||
const records = []
|
||||
ctx.logger.exporter({ levels: { default: 3 }, export: (message) => records.push(message) })
|
||||
try {
|
||||
const mem = mod.apply(ctx, {
|
||||
path: join(dir, 'm.db'),
|
||||
embedder: failingEmbedder,
|
||||
})
|
||||
await mem.contextForStep(ask('值班室分机号是多少'))
|
||||
const recalled = records.filter(
|
||||
(record) => record.name === 'dsh-memory' && String(record.args[1] ?? '').startsWith('recall '),
|
||||
)
|
||||
assert.deepEqual(
|
||||
recalled,
|
||||
[],
|
||||
'under the mutation a fault is silent with debugLog off, so L4 must fail',
|
||||
)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// ── L8: the line must actually be READABLE in a real process ──────────────
|
||||
//
|
||||
// Measured: cordis ships one default exporter (an in-memory ring buffer) and no
|
||||
// dsh bundle installs a terminal one, so `ctx.logger.info(...)` in a real profile
|
||||
// lands in a buffer nobody drains. A "we added logging" change that only reaches
|
||||
// that buffer would be green here and still print nothing in `dsh headless`.
|
||||
// So this asserts on process.stderr, which is where an operator can see it.
|
||||
test('L8: the summary reaches stderr, gated by debugLog, and never stdout', async () => {
|
||||
const originalWrite = process.stderr.write
|
||||
const stderr = []
|
||||
const stdout = []
|
||||
const originalOutWrite = process.stdout.write
|
||||
process.stderr.write = (chunk, ...rest) => {
|
||||
stderr.push(String(chunk))
|
||||
return originalWrite.call(process.stderr, chunk, ...rest)
|
||||
}
|
||||
process.stdout.write = (chunk, ...rest) => {
|
||||
stdout.push(String(chunk))
|
||||
return originalOutWrite.call(process.stdout, chunk, ...rest)
|
||||
}
|
||||
|
||||
const runTurn = async (config) => {
|
||||
stderr.length = 0
|
||||
const app = await makeMem(config)
|
||||
try {
|
||||
await app.mem.commit({ text: '值班室分机号 4324,值班人 老周' })
|
||||
stderr.length = 0 // drop anything the commit produced
|
||||
await app.mem.contextForStep(ask('值班室分机号是多少'))
|
||||
return stderr.join('')
|
||||
} finally {
|
||||
await app.done()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const loud = await runTurn({ debugLog: true })
|
||||
assert.match(loud, /^dsh-memory: info: recall query=9ch/m, `want one info line on stderr; got: ${JSON.stringify(loud)}`)
|
||||
|
||||
const quiet = await runTurn({})
|
||||
assert.equal(quiet, '', `debugLog=false must print nothing for a healthy turn; got: ${JSON.stringify(quiet)}`)
|
||||
|
||||
assert.deepEqual(stdout, [], `stdout must never be written; got ${JSON.stringify(stdout)}`)
|
||||
} finally {
|
||||
process.stderr.write = originalWrite
|
||||
process.stdout.write = originalOutWrite
|
||||
}
|
||||
})
|
||||
|
||||
test('L8b: foreign loggers are not reprinted under this plugin’s prefix', async () => {
|
||||
// The exporters map is process-wide, so a sink without a name check would
|
||||
// duplicate every other plugin's warnings — turning an observability change
|
||||
// into an amplification bug.
|
||||
const originalWrite = process.stderr.write
|
||||
const stderr = []
|
||||
process.stderr.write = (chunk, ...rest) => {
|
||||
stderr.push(String(chunk))
|
||||
return originalWrite.call(process.stderr, chunk, ...rest)
|
||||
}
|
||||
const { ctx, mem, done } = await makeMem({ debugLog: true })
|
||||
try {
|
||||
stderr.length = 0
|
||||
ctx.logger('some-other-plugin').warn('%s', 'not mine')
|
||||
ctx.logger('some-other-plugin').info('%s', 'also not mine')
|
||||
void mem
|
||||
assert.equal(stderr.join(''), '', `another plugin's records must not be reprinted; got: ${JSON.stringify(stderr)}`)
|
||||
} finally {
|
||||
process.stderr.write = originalWrite
|
||||
await done()
|
||||
}
|
||||
})
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// L10 · ORDINARY_DEGRADATIONS must actually DECIDE the level
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// P1 added two healthy modes to `lib/recall.js` (`hybrid`, `no_vector_space`) and
|
||||
// registered both in `ORDINARY_DEGRADATIONS`. Nothing tested that registration:
|
||||
// `grep ORDINARY_DEGRADATIONS test/` was empty, and every P1-era assertion checked
|
||||
// the LABEL (`hits.degraded === 'hybrid'`), never the LOG LEVEL. Deleting `'hybrid'`
|
||||
// from the set therefore kept all 268 tests green while a healthy wired recall
|
||||
// started emitting `warn` on every turn — a false fault alarm on the happiest path.
|
||||
//
|
||||
// The three cases below are deliberately in ONE group so the contrast is visible:
|
||||
// two healthy modes must be silent, one real fault must speak, all at the same
|
||||
// verbosity (`debugLog: false`, the shipping default).
|
||||
|
||||
/**
|
||||
* An embedder whose vectors put every block and the query on the same axis.
|
||||
*
|
||||
* Deterministic and transport-free: "a vector exists in this space" is the only
|
||||
* fact these cases need, and a real provider would add neither coverage nor
|
||||
* determinism.
|
||||
*
|
||||
* @param {string} fingerprint
|
||||
*/
|
||||
function fixedEmbedder(fingerprint) {
|
||||
return {
|
||||
async embedText() { return [1, 0, 0] },
|
||||
info: () => ({ fingerprint, dimension: 3 }),
|
||||
}
|
||||
}
|
||||
|
||||
const L10_SPACE = 'fp-l10'
|
||||
|
||||
/**
|
||||
* Seed one block that carries a vector in the active space.
|
||||
*
|
||||
* The explicit vector+fingerprint pair goes through the documented
|
||||
* `commit({ vector, fingerprint })` path, so the write is a normal store write
|
||||
* rather than a fixture shortcut: `recallBlocksHybrid` sees a real row.
|
||||
*
|
||||
* @param {{mem: object}} app
|
||||
* @param {string} text
|
||||
*/
|
||||
async function commitVectored(app, text) {
|
||||
await app.mem.commit({ text, vector: [1, 0, 0], fingerprint: L10_SPACE })
|
||||
}
|
||||
|
||||
/** The criterion, shared by the real module and every mutant. */
|
||||
async function assertLevelContract(build) {
|
||||
// ── (1) `hybrid` is silent at the default verbosity ─────────────────────
|
||||
//
|
||||
// Preconditions asserted, not assumed: the space really holds a comparable
|
||||
// vector, the hit really carries a vector contribution, and the label really is
|
||||
// `hybrid`. Without them a silent log would be indistinguishable from a build
|
||||
// that never reached the vector route at all.
|
||||
const hybridApp = await build({ embedder: fixedEmbedder(L10_SPACE) })
|
||||
let hybridText
|
||||
let hybridHits
|
||||
try {
|
||||
await commitVectored(hybridApp, '值班室分机号 4324')
|
||||
hybridText = await hybridApp.mem.contextForStep(ask('值班室分机号是多少'))
|
||||
hybridHits = hybridApp.mem.lastRecall
|
||||
const emitted = hybridApp.lines()
|
||||
assert.equal(hybridHits?.mode, 'hybrid', `precondition: the wired route must be labelled hybrid; got ${JSON.stringify(hybridHits)}`)
|
||||
assert.notEqual(hybridText, '', 'precondition: the block must actually be recalled')
|
||||
assert.deepEqual(
|
||||
emitted,
|
||||
[],
|
||||
'L10.1: a HEALTHY wired recall must log nothing with debugLog off. ' +
|
||||
'A `warn` here is a false fault alarm on the happiest path, every turn. ' +
|
||||
`got ${JSON.stringify(emitted)}`,
|
||||
)
|
||||
} finally {
|
||||
await hybridApp.done()
|
||||
}
|
||||
|
||||
// ── (2) `no_vector_space` is silent too ─────────────────────────────────
|
||||
//
|
||||
// A configured embedder whose space holds no usable vector. This is a DATA
|
||||
// state (fresh space), not a provider fault, so it must not warn either.
|
||||
//
|
||||
// ★ The block must be written while NO embedder is mounted, otherwise `commit`
|
||||
// embeds it and the space is no longer empty — a fixture that quietly fills the
|
||||
// space it is supposed to leave bare would make this case assert nothing (it did,
|
||||
// on the first attempt: the label came back `hybrid`).
|
||||
const emptyApp = await build({})
|
||||
try {
|
||||
await emptyApp.mem.commit({ text: '值班室分机号 4324' })
|
||||
emptyApp.mem.embedder = fixedEmbedder('fp-l10-empty')
|
||||
await emptyApp.mem.contextForStep(ask('值班室分机号是多少'))
|
||||
const mode = emptyApp.mem.lastRecall?.mode
|
||||
assert.equal(mode, 'no_vector_space', `precondition: an empty space must report no_vector_space; got ${mode}`)
|
||||
assert.deepEqual(
|
||||
emptyApp.lines(),
|
||||
[],
|
||||
'L10.2: `no_vector_space` is a standing data state, not a fault — it must not warn. ' +
|
||||
`got ${JSON.stringify(emptyApp.lines())}`,
|
||||
)
|
||||
} finally {
|
||||
await emptyApp.done()
|
||||
}
|
||||
|
||||
// ── (3) `embed_error` DOES speak, at `warn`, exactly once ───────────────
|
||||
//
|
||||
// The control. Without it, "silent" could be satisfied by a build that never
|
||||
// logs at all — which is the bug the whole log channel exists to fix (L4).
|
||||
const faultApp = await build({ embedder: failingEmbedder })
|
||||
try {
|
||||
await faultApp.mem.commit({ text: '值班室分机号 4324' })
|
||||
await faultApp.mem.contextForStep(ask('值班室分机号是多少'))
|
||||
const recalled = faultApp.lines().filter((line) => line.text.startsWith('recall '))
|
||||
assert.equal(recalled.length, 1, `L10.3: a fault must produce exactly one recall line; got ${JSON.stringify(recalled)}`)
|
||||
assert.equal(recalled[0].type, 'warn', `L10.3: a fault is a warn; got ${recalled[0].type}`)
|
||||
assert.match(recalled[0].text, /degraded=embed_error/, 'and names the fault')
|
||||
} finally {
|
||||
await faultApp.done()
|
||||
}
|
||||
}
|
||||
|
||||
test('L10: healthy modes are silent and faults warn — the same verbosity, three outcomes', async () => {
|
||||
await assertLevelContract(makeMem)
|
||||
})
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// L11 · `minScore` filtering everything is NOT `no_vector_space`
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Measured behaviour (pinned because "everything was filtered out" looks like a
|
||||
// bug to a future reader, and the tempting fix — call it `no_vector_space` — would
|
||||
// erase the distinction between "there was nothing to score" and "the threshold is
|
||||
// too high"). `no_vector_space` is returned at exactly one place,
|
||||
// `recall.js`'s `vectorScores.size === 0`; a fused run that produced candidates
|
||||
// and then gated them all keeps the `hybrid` label with `hits: []`.
|
||||
|
||||
test('L11: a fused recall gated to zero hits by minScore still reports hybrid, and stays silent', async () => {
|
||||
const { mem, lines, done } = await makeMem({ embedder: fixedEmbedder(L10_SPACE) })
|
||||
try {
|
||||
await commitVectored({ mem }, '值班室分机号 4324')
|
||||
const question = '值班室分机号是多少'
|
||||
|
||||
// ★ `minScore` is applied PER PATH against the RAW VECTOR score (V5), not
|
||||
// against the fused 0.7. The block's vector score is 1.0, so the threshold
|
||||
// that removes it has to exceed 1.0.
|
||||
//
|
||||
// ★ Driven through `recallBlocksHybrid` rather than `mem.recall`: the
|
||||
// service method forwards only `topK`/`signal`, so a threshold passed there
|
||||
// would never reach the scorer and this precondition would silently pass on an
|
||||
// unchanged result.
|
||||
const raw = await recallBlocksHybrid(mem.store, fixedEmbedder(L10_SPACE), question, {
|
||||
topK: 3,
|
||||
minScore: 1.5,
|
||||
})
|
||||
assert.deepEqual(
|
||||
[...raw].map((hit) => hit.text),
|
||||
[],
|
||||
`precondition: the threshold must remove every candidate; got ${JSON.stringify(
|
||||
[...raw].map((hit) => hit.text),
|
||||
)}`,
|
||||
)
|
||||
assert.equal(
|
||||
raw.degraded,
|
||||
'hybrid',
|
||||
`a gated recall is NOT an empty space: the vector route did score candidates. Got ${raw.degraded}`,
|
||||
)
|
||||
assert.equal(raw.degradedError, undefined, 'and it carries no error')
|
||||
|
||||
// The log decision is the subject of L10, so drive the real method with the
|
||||
// real gated result. Nothing here is a stub: `logRecallTurn` is what the
|
||||
// plugin calls on every turn.
|
||||
mem.logRecallTurn({
|
||||
query: question,
|
||||
messages: ask(question),
|
||||
raw,
|
||||
segmented: segmentRecallBudget(raw, { topK: 3 }),
|
||||
injected: '',
|
||||
})
|
||||
assert.deepEqual(
|
||||
lines(),
|
||||
[],
|
||||
`L11: a hybrid recall with zero surviving hits must still be silent with debugLog off; got ${JSON.stringify(lines())}`,
|
||||
)
|
||||
} finally {
|
||||
await done()
|
||||
}
|
||||
})
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// L10† / L11† · the mutations that make the two silent assertions bite
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Copy the package, rewrite ONE anchor in `lib/index.js`, import it fresh.
|
||||
*
|
||||
* Same shape as L4† (and the mutation discipline note in docs/phase-b-criteria.md):
|
||||
* the anchor must occur exactly once, or the test fails loudly rather than
|
||||
* silently asserting against unmutated source.
|
||||
*
|
||||
* @param {string} anchor exact source text, expected once
|
||||
* @param {string} replacement
|
||||
* @param {(apply: Function) => Promise<void>} body receives the mutated `apply`
|
||||
*/
|
||||
async function withMutatedIndex(anchor, replacement, body) {
|
||||
const { readFileSync, writeFileSync, mkdtempSync, rmSync, cpSync } = await import('node:fs')
|
||||
const { tmpdir } = await import('node:os')
|
||||
const { join } = await import('node:path')
|
||||
const { fileURLToPath, pathToFileURL } = await import('node:url')
|
||||
|
||||
const libDir = fileURLToPath(new URL('../lib/', import.meta.url))
|
||||
const pkgRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
const dir = mkdtempSync(join(tmpdir(), 'mlog-l8-'))
|
||||
try {
|
||||
cpSync(libDir, join(dir, 'lib'), { recursive: true })
|
||||
cpSync(join(pkgRoot, 'package.json'), join(dir, 'package.json'))
|
||||
try {
|
||||
cpSync(join(pkgRoot, 'node_modules'), join(dir, 'node_modules'), {
|
||||
recursive: true,
|
||||
dereference: false,
|
||||
})
|
||||
} catch {
|
||||
// A poetry-free install fails loudly in the body below, which is correct.
|
||||
}
|
||||
|
||||
const target = join(dir, 'lib', 'index.js')
|
||||
const source = readFileSync(target, 'utf8')
|
||||
assert.equal(
|
||||
source.split(anchor).length - 1,
|
||||
1,
|
||||
`mutation anchor must occur exactly once — did the set move?\n${anchor}`,
|
||||
)
|
||||
writeFileSync(target, source.replace(anchor, replacement))
|
||||
|
||||
const mod = await import(pathToFileURL(target).href)
|
||||
await body((ctx, config) => mod.apply(ctx, config))
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, removed, bites] of [
|
||||
['L10\u2020', "'hybrid'", 'L10.1'],
|
||||
['L11\u2020', "'no_vector_space'", 'L10.2'],
|
||||
]) {
|
||||
test(`${name}: removing ${removed} from ORDINARY_DEGRADATIONS turns ${bites} red`, async (t) => {
|
||||
const anchor = " 'ok', 'hybrid', 'no_embedder', 'aborted', 'no_vector_space', 'vector_ready_unused',"
|
||||
const replacement = [
|
||||
' // MUTATED: one healthy mode de-registered',
|
||||
" 'ok', 'no_embedder', 'aborted',",
|
||||
removed === "'hybrid'" ? " 'no_vector_space', 'vector_ready_unused'," : " 'hybrid', 'vector_ready_unused',",
|
||||
].join('\n')
|
||||
|
||||
await withMutatedIndex(anchor, replacement, async (apply) => {
|
||||
// The SAME criterion body the unmutated build passes. It must throw, and the
|
||||
// throw must be the assertion's own — a different error (a bad import, a
|
||||
// missing node_modules) would make this test green for the wrong reason.
|
||||
await assert.rejects(
|
||||
() => assertLevelContract((config) => makeMemWith(apply, config)),
|
||||
(error) => {
|
||||
assert.equal(error.code, 'ERR_ASSERTION', `only the criterion may bite; got ${error.code}: ${error.message}`)
|
||||
assert.ok(
|
||||
String(error.message).startsWith(bites),
|
||||
`the biting assertion must be ${bites}; got: ${String(error.message).split('\n')[0]}`,
|
||||
)
|
||||
t.diagnostic(`${name} RAW RED >>> ${String(error.message).split('\n')[0]}`)
|
||||
return true
|
||||
},
|
||||
`${name}: with ${removed} de-registered, ${bites} must go red`,
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
101
test/packaging.test.js
Normal file
101
test/packaging.test.js
Normal file
@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 打包完整性:`npm pack` 的产物必须能真的被加载。
|
||||
*
|
||||
* ## 这条判据为什么存在
|
||||
*
|
||||
* `package.json` 的 `files` 原本是
|
||||
*
|
||||
* ["lib/*.js", "lib/loop.js", "lib/loop/*.js", "lib/types/*.d.ts"]
|
||||
*
|
||||
* 而 `lib/*.js` 只匹配 lib/ 下一层。于是 `lib/embed/http.js` —— `lib/index.js`
|
||||
* 第 35 行 `import { createHttpEmbedder } from './embed/http.js'` 的目标 ——
|
||||
* 从来没有被打进包。
|
||||
*
|
||||
* 这个缺陷活下来的原因很具体:`npm pack --dry-run` 一直打印「10 files」,
|
||||
* 数字看起来正常,没有人去读那十个文件名;而仓库里所有测试都是从源码树跑
|
||||
* 的,源码树里那个文件当然存在。**只有在「从 tarball 安装」这条路径上才会
|
||||
* 暴露**,而那正是发布出去的那条路径。
|
||||
*
|
||||
* 实测(修复前,把 tarball 解开后 import):
|
||||
*
|
||||
* IMPORT FAILED: ERR_MODULE_NOT_FOUND
|
||||
* Cannot find module '.../package/lib/embed/http.js' imported from '.../package/lib/index.js'
|
||||
*
|
||||
* 修好之后同一条命令:`IMPORT OK, exports: StableMem,apply,default,...`
|
||||
*
|
||||
* ## 判据形态
|
||||
*
|
||||
* 静态、无副作用、不跑 npm:把 `files` 里的每个模式编译成正则,然后要求
|
||||
* **lib/ 下的每一个 .js 都至少被一个模式匹配**。运行时需要的文件全在 lib/ 下,
|
||||
* 所以这条覆盖了「漏文件」这一类,不需要维护一份白名单。
|
||||
*
|
||||
* 另加一条:`lib/index.js` 里所有相对 import 的目标必须存在于源码树 ——
|
||||
* 否则上面那条可能在「文件根本不存在」时也绿(模式匹配不到不存在的文件)。
|
||||
*
|
||||
* @module dsh-memory/test/packaging
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const ROOT = new URL('..', import.meta.url)
|
||||
|
||||
/** 把 npm `files` 里的一条模式编译成正则(`*` 不跨目录,与 npm 一致)。 */
|
||||
function patternToRegExp(pattern) {
|
||||
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')
|
||||
return new RegExp(`^${escaped}$`)
|
||||
}
|
||||
|
||||
/** lib/ 下所有 .js 文件的相对路径。 */
|
||||
function libFiles() {
|
||||
const out = []
|
||||
const walk = (relative) => {
|
||||
for (const entry of readdirSync(new URL(relative + '/', ROOT), { withFileTypes: true })) {
|
||||
const path = `${relative}/${entry.name}`
|
||||
if (entry.isDirectory()) walk(path)
|
||||
else if (entry.name.endsWith('.js')) out.push(path)
|
||||
}
|
||||
}
|
||||
walk('lib')
|
||||
return out.sort()
|
||||
}
|
||||
|
||||
test('packaging: every file under lib/ is covered by package.json "files"', () => {
|
||||
const pkg = JSON.parse(readFileSync(new URL('package.json', ROOT), 'utf8'))
|
||||
assert.ok(Array.isArray(pkg.files), 'package.json must declare a "files" allowlist')
|
||||
const patterns = pkg.files.map(patternToRegExp)
|
||||
|
||||
const missing = libFiles().filter((path) => !patterns.some((re) => re.test(path)))
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
'these files live under lib/ but no "files" pattern reaches them, so they will be '
|
||||
+ 'absent from the published tarball. `lib/*.js` matches ONE level only — a module '
|
||||
+ 'in a subdirectory needs its own pattern. This is how lib/embed/http.js went '
|
||||
+ 'missing while `npm pack --dry-run` happily reported a count:\n '
|
||||
+ missing.join('\n '),
|
||||
)
|
||||
})
|
||||
|
||||
test('packaging: every relative import in lib/index.js resolves on disk', () => {
|
||||
const source = readFileSync(new URL('lib/index.js', ROOT), 'utf8')
|
||||
const specifiers = [...source.matchAll(/from\s+'(\.[^']+)'/g)].map((m) => m[1])
|
||||
assert.ok(specifiers.length > 0, 'precondition: lib/index.js must have relative imports')
|
||||
|
||||
const unresolved = specifiers.filter((specifier) => {
|
||||
try {
|
||||
const url = new URL(specifier, new URL('lib/index.js', ROOT))
|
||||
readFileSync(url)
|
||||
return false
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
})
|
||||
assert.deepEqual(
|
||||
unresolved,
|
||||
[],
|
||||
`lib/index.js imports modules that do not exist: ${unresolved.join(', ')}`,
|
||||
)
|
||||
})
|
||||
1092
test/phase-b-a.test.js
Normal file
1092
test/phase-b-a.test.js
Normal file
File diff suppressed because it is too large
Load Diff
751
test/phase-b-c4-centering.test.js
Normal file
751
test/phase-b-c4-centering.test.js
Normal file
@ -0,0 +1,751 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { BlockStore } from '../lib/store.js'
|
||||
import { recallBlocksHybrid } from '../lib/recall.js'
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// C4 · 中心化校正(graph_centroid)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// 判据来源 docs/phase-b-criteria.md:253
|
||||
// C4 校正时查询向量与块向量都去均值,不能只处理一边
|
||||
// 依据 centerBoth(centroid_store.go:443-451)
|
||||
// + 注释「只处理一边等于混两种量纲」
|
||||
//
|
||||
// ★ 为什么必须自建空间,不能复用 P1 的语料
|
||||
// P1 的假 embedder 是 8 族正交 one-hot:每条块独占一维,均值向量趋近 0
|
||||
// (实测 mean norm = 0.3536 < MEAN_THRESHOLD 0.6)⇒ isAnomalous = false
|
||||
// ⇒ 正确门控的 C4 会自动关闭去均值 ⇒ 判据全绿但什么都没验到。
|
||||
// 所以这里手工构造各向异性空间:所有块共享一个强背景方向,
|
||||
// 各自只叠一个小 offset,均值范数才会高到触发校正。
|
||||
//
|
||||
// ★ 三条判据的分工(别混)
|
||||
// C4a born-RED 未实现时红(裸余弦 top1 = hub,去均值后应为 target)
|
||||
// C4b mutation-GUARD 未实现时绿(裸余弦下同值对本来就 = 1),
|
||||
// 只有「只减一边」能让它红 —— 抓 C4 的病根。
|
||||
// 实测:只减 query 时 C4a 的 top1 仍然是 target,
|
||||
// 所以 C4a 抓不到这个错误,必须靠 C4b。
|
||||
// C4c guard 无中心时照常返回结果,只是不校正
|
||||
//
|
||||
// ★ What this does NOT establish
|
||||
// 中心化改变的是向量间的夹角,因此能改变排序。
|
||||
// 它不能让一个没校准的绝对分数变得可用:去均值之后余弦仍然是余弦,
|
||||
// 而 DEFAULT_MIN_SCORE = 0.5 从来不是从中心化后的分布推出来的。
|
||||
// 阈值校准仍是 phase-b-p1-vector-wiring.test.js:51-58 记录的已知未解问题。
|
||||
|
||||
const DIM = 8
|
||||
const SPACE = 'c4:centering:test'
|
||||
|
||||
// ── 空间常数(钉死;改一个就必须重测翻转窗口)────────────────────────────
|
||||
const HUB_EPS = 0.02
|
||||
const TARGET_EPS = 0.30
|
||||
const QUERY_WEIGHT = 0.40
|
||||
const MEAN_THRESHOLD = 0.6
|
||||
|
||||
const norm = (v) => Math.sqrt(v.reduce((s, x) => s + x * x, 0))
|
||||
const unit = (v) => { const n = norm(v); return n === 0 ? v : v.map((x) => x / n) }
|
||||
const axis = (i) => unit(Array.from({ length: DIM }, (_, k) => (k === i ? 1 : 0)))
|
||||
|
||||
// 共同背景方向:所有块都携带它,这是各向异性的来源
|
||||
const BACKBONE = unit([1.0, 0.55, 0.42, 0.30, 0.22, 0.16, 0.11, 0.07])
|
||||
const TARGET_AXIS = axis(7)
|
||||
const mk = (eps, ax) => unit(BACKBONE.map((x, i) => x + eps * ax[i]))
|
||||
|
||||
const HUB_VEC = mk(HUB_EPS, axis(1))
|
||||
const TARGET_VEC = mk(TARGET_EPS, TARGET_AXIS)
|
||||
const DISTRACTOR_VECS = [0, 2, 3, 4, 5, 6].map((i) => mk(TARGET_EPS, axis(i)))
|
||||
|
||||
const QUERY_TEXT = '上次说的那个四位数是什么来着?'
|
||||
const QUERY_VEC = unit(BACKBONE.map((x, i) => x + TARGET_EPS * QUERY_WEIGHT * TARGET_AXIS[i]))
|
||||
|
||||
const HUB = { key: 'hub', text: '这段时间一切照旧,没有什么特别的事情。', vec: HUB_VEC }
|
||||
const TARGET = { key: 'target', text: '第七号仓库的备用门禁码是 QP47-812。', vec: TARGET_VEC }
|
||||
const DISTRACTORS = DISTRACTOR_VECS.map((vec, i) => ({
|
||||
key: `d${i}`, text: `干扰块 ${i}:与本问无关的日常事项。`, vec,
|
||||
}))
|
||||
const CORPUS = [HUB, TARGET, ...DISTRACTORS]
|
||||
|
||||
const byText = new Map(CORPUS.map((b) => [b.text, b.vec]))
|
||||
|
||||
function makeEmbedder() {
|
||||
return {
|
||||
async embedText(text) {
|
||||
if (byText.has(text)) return byText.get(text)
|
||||
if (text === QUERY_TEXT) return QUERY_VEC
|
||||
return new Array(DIM).fill(0)
|
||||
},
|
||||
info: () => ({ fingerprint: SPACE, dimension: DIM }),
|
||||
}
|
||||
}
|
||||
|
||||
/** 建一个装满 CORPUS 的库,返回 store 与 id→key 的映射。 */
|
||||
function seedStore({ withCentroid = true } = {}) {
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
const keyOfId = new Map()
|
||||
for (const block of CORPUS) {
|
||||
const written = store.putBlock({ text: block.text, vector: block.vec, fingerprint: SPACE })
|
||||
keyOfId.set(written.id, block.key)
|
||||
}
|
||||
if (withCentroid) store.rebuildCentroid(SPACE)
|
||||
return { store, keyOfId }
|
||||
}
|
||||
|
||||
/** 召回并按 key 呈现,便于断言。 */
|
||||
async function recallByKey(store, keyOfId, options = {}) {
|
||||
const hits = await recallBlocksHybrid(store, makeEmbedder(), QUERY_TEXT, { topK: 8, ...options })
|
||||
return hits.map((hit) => ({ key: keyOfId.get(hit.id) ?? hit.id, score: hit.score, vectorHit: hit.vectorHit }))
|
||||
}
|
||||
|
||||
const fmt = (hits) => hits.map((h) => `${h.key}=${h.score.toFixed(4)}(v=${Number(h.vectorHit ?? 0).toFixed(4)})`).join(' ')
|
||||
|
||||
// ── 前置:空间真的会触发校正 ────────────────────────────────────────────
|
||||
//
|
||||
// 没有这三条,后面任何断言都可能因为「校正压根没启用」而通过,那是假绿。
|
||||
test('C4-0: the fixture space is anisotropic enough to enable centering', () => {
|
||||
const { store } = seedStore()
|
||||
try {
|
||||
const centroid = store.loadCentroid(SPACE)
|
||||
assert.ok(centroid !== undefined, 'precondition: a centroid must exist after rebuildCentroid')
|
||||
assert.equal(centroid.isAnomalous, true, 'precondition: the space must be judged anisotropic')
|
||||
assert.ok(
|
||||
norm(centroid.mean) >= MEAN_THRESHOLD,
|
||||
`precondition: mean norm ${norm(centroid.mean).toFixed(6)} must clear MEAN_THRESHOLD ${MEAN_THRESHOLD}`,
|
||||
)
|
||||
assert.equal(centroid.dim, DIM, 'precondition: centroid dimension must match the space')
|
||||
assert.equal(centroid.vectorCount, CORPUS.length, 'precondition: every block contributed')
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── C4a: 校正把 top-1 从 hub 翻到 target ────────────────────────────────
|
||||
//
|
||||
// born-RED:在有中心但不去均值的构建上,裸余弦会让 hub 赢
|
||||
// (实测 hub 0.9928 > target 0.9859),因为 hub 几乎就是背景本身,
|
||||
// 而背景是 query 与所有块共有的最大分量。
|
||||
test('C4a: centering flips top-1 from the hub to the target', async (t) => {
|
||||
const { store, keyOfId } = seedStore()
|
||||
try {
|
||||
const hits = await recallByKey(store, keyOfId)
|
||||
t.diagnostic(`C4a ranking: ${fmt(hits)}`)
|
||||
|
||||
assert.ok(hits.length > 0, 'precondition: the recall must return something')
|
||||
assert.equal(
|
||||
hits[0].key,
|
||||
'target',
|
||||
'C4a: after centering the target must be top-1. ' +
|
||||
'If this is "hub", the space mean is still dominating the score — either ' +
|
||||
'centering is not wired at all, or only one side was centered. ' +
|
||||
`got ${fmt(hits)}`,
|
||||
)
|
||||
// 分数也要过闸,否则「top1 对了」但整轮被 minScore 清空就成了空断言
|
||||
assert.ok(
|
||||
hits[0].score > 0.5,
|
||||
`C4a: the winning score must clear DEFAULT_MIN_SCORE 0.5; got ${hits[0].score}`,
|
||||
)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── C4b: 同值对的余弦恒为 1(mutation-GUARD)──────────────────────────────
|
||||
//
|
||||
// 这是唯一一条**不依赖语料构造、纯代数必然成立**的性质:
|
||||
// 若块向量与查询向量完全相同,余弦必须恰好是 1 —— 不论是否中心化。
|
||||
// · 两侧都减同一个均值 ⇒ (v-m) 与 (v-m) 的余弦 = 1 ✓
|
||||
// · 只减一边 ⇒ (v-m) 与 v 的余弦 ≠ 1 ✗ ← M-C4 正是这个
|
||||
//
|
||||
// ★ 它在未实现的构建上是绿的(裸余弦下同值对本来就 = 1),
|
||||
// 所以它不是 born-red 判据,而是抓 M-C4 的那张网。别因为它「一开始就绿」删掉它:
|
||||
// 实测「只减 query」时 C4a 的 top-1 仍然是 target,C4a 抓不到这个错误。
|
||||
test('C4b: a block identical to the query always scores a cosine of 1', async (t) => {
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
try {
|
||||
// 一条与 query 完全同值的块,外加两条背景块,凑出会触发校正的空间
|
||||
const twin = store.putBlock({ text: '与查询同值的那条', vector: QUERY_VEC, fingerprint: SPACE })
|
||||
for (const vec of [HUB_VEC, ...DISTRACTOR_VECS.slice(0, 3)]) {
|
||||
store.putBlock({ text: `背景块 ${Math.random()}`, vector: vec, fingerprint: SPACE })
|
||||
}
|
||||
store.rebuildCentroid(SPACE)
|
||||
|
||||
const hits = await recallBlocksHybrid(store, makeEmbedder(), QUERY_TEXT, { topK: 8, minScore: 0 })
|
||||
const twinHit = hits.find((hit) => hit.id === twin.id)
|
||||
assert.ok(twinHit !== undefined, `C4b: precondition — the twin block must be scored; got ${hits.length} hits`)
|
||||
t.diagnostic(`C4b twin vectorHit = ${twinHit.vectorHit}`)
|
||||
|
||||
assert.ok(
|
||||
Math.abs(twinHit.vectorHit - 1) < 1e-9,
|
||||
'C4b: an identical vector pair must score exactly 1. ' +
|
||||
'A value near 0.1897 on this 5-block fixture is the signature of centering ONE '
|
||||
+ 'side only ' +
|
||||
'(M-C4) — subtracting the mean from the query but not the block mixes ' +
|
||||
'two coordinate systems and the cosine stops being an angle. ' +
|
||||
`got ${twinHit.vectorHit}`,
|
||||
)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── C4c: 没有中心时照常返回结果,只是不校正 ──────────────────────────────
|
||||
//
|
||||
// criteria 里写死了:C3 判 stale 的那一轮「仍返回结果(用未校正向量)」。
|
||||
// 这条守的是「拿不到中心就让召回失败」这个错误,不是「校正对不对」。
|
||||
test('C4c: without a centroid the recall still returns results, only uncorrected', async (t) => {
|
||||
const { store, keyOfId } = seedStore({ withCentroid: false })
|
||||
try {
|
||||
assert.equal(store.loadCentroid(SPACE), undefined, 'precondition: no centroid exists in this fixture')
|
||||
|
||||
const hits = await recallByKey(store, keyOfId)
|
||||
t.diagnostic(`C4c uncorrected ranking: ${fmt(hits)}`)
|
||||
|
||||
assert.ok(hits.length > 0, 'C4c: a missing centroid must not empty the result set')
|
||||
assert.equal(
|
||||
hits[0].key,
|
||||
'hub',
|
||||
'C4c: without a centroid the ranking stays uncorrected, so the hub still wins. ' +
|
||||
'If this is "target", something is centering without a centroid to center on. ' +
|
||||
`got ${fmt(hits)}`,
|
||||
)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── C4d: 生产路径必须自己把中心建出来 ────────────────────────────────────
|
||||
//
|
||||
// ★ 这条是 C4 的第一层,也是最容易被漏掉的一半。
|
||||
// C4a/b/c 都用 `seedStore()` 手工调 `rebuildCentroid`,所以它们只能证明
|
||||
// 「去均值的算术是对的」,证明不了「生产上中心真的会出现」。
|
||||
// 而生产上恰恰是后者不存在:`saveSpaceCentroid` / `centroidStatus` /
|
||||
// `centroidRecallState` 在 lib/ 里都没有生产调用者,`rebuildCentroid`
|
||||
// 只有测试在调 —— `graph_centroid` 曾经永远是空表。
|
||||
//
|
||||
// 所以这条判据走真 `apply()` + 真 `commit()`,不碰 store 内部:
|
||||
// 写完之后 `loadCentroid` 必须已经能读到中心,且召回必须已经用上校正。
|
||||
test('C4d: the production commit path builds the centroid it later reads', async () => {
|
||||
const { Context } = await import('@deepseek-ai/cordis')
|
||||
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
|
||||
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
|
||||
const { apply: applyMemory } = await import('../lib/index.js')
|
||||
const { mkdtempSync, rmSync } = await import('node:fs')
|
||||
const { tmpdir } = await import('node:os')
|
||||
const { join } = await import('node:path')
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'c4d-'))
|
||||
const ctx = new Context()
|
||||
new SystemPrompt(ctx, {})
|
||||
new ToolRuntime(ctx, {})
|
||||
try {
|
||||
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), embedder: makeEmbedder() })
|
||||
|
||||
// 前置:还没写东西时不该有中心
|
||||
assert.equal(
|
||||
mem.store.loadCentroid(SPACE),
|
||||
undefined,
|
||||
'precondition: a fresh database has no centroid',
|
||||
)
|
||||
|
||||
// 全部走生产入口,不手工调 rebuildCentroid
|
||||
const keyOfId = new Map()
|
||||
for (const block of CORPUS) {
|
||||
const written = await mem.commit({ text: block.text })
|
||||
keyOfId.set(written.id, block.key)
|
||||
assert.equal(written.embedded, true, `precondition: ${block.key} must actually be embedded`)
|
||||
}
|
||||
|
||||
// ★ The first commit builds a centroid from the blocks it has seen SO FAR,
|
||||
// so right after a burst of writes the centroid still describes only the
|
||||
// first one — and with MIN_SAMPLE it is not even allowed to claim a direction
|
||||
// yet. The write path schedules a drift rebuild; wait for it before asserting
|
||||
// on the centroid's CONTENTS rather than merely on its existence.
|
||||
//
|
||||
// Measured without this wait: vectorCount=1, meanLength=1.0000 on the same
|
||||
// fixture, and a 1-block "mean" is that block itself.
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
|
||||
const centroid = mem.store.loadCentroid(SPACE)
|
||||
assert.ok(
|
||||
centroid !== undefined,
|
||||
'C4d: after commits through the production path a centroid must exist. ' +
|
||||
'If this is undefined, nothing in lib/ ever builds one and centering can ' +
|
||||
'never apply in real use — the arithmetic tests above would still pass ' +
|
||||
'because they build the centroid by hand.',
|
||||
)
|
||||
assert.equal(centroid.isAnomalous, true, 'C4d: and it must be judged anisotropic for this fixture')
|
||||
assert.equal(centroid.dim, DIM, 'C4d: its width must match the space')
|
||||
// ★ The existence check above passes even when the centroid describes a
|
||||
// single block — which is exactly the state right after the first commit.
|
||||
// A 1-block mean is that block itself (norm 1.0, so it clears the 0.6
|
||||
// threshold by construction), and centering by it drops that block as a zero
|
||||
// vector. Pin the coverage, not just the presence.
|
||||
assert.equal(
|
||||
centroid.vectorCount,
|
||||
CORPUS.length,
|
||||
'C4d: the centroid must describe every block in the space, not only the ones '
|
||||
+ 'present at the first commit. A smaller count means the drift rebuild '
|
||||
+ 'never caught up and centering is subtracting the wrong mean.',
|
||||
)
|
||||
|
||||
// 端到端:同一个库,召回必须已经用上校正
|
||||
const hits = await recallBlocksHybrid(mem.store, makeEmbedder(), QUERY_TEXT, { topK: 8 })
|
||||
assert.ok(hits.length > 0, 'C4d: the recall must return something')
|
||||
const top = hits[0]
|
||||
assert.equal(
|
||||
keyOfId.get(top.id),
|
||||
'target',
|
||||
'C4d: the whole point — production write + production read must together ' +
|
||||
'produce the corrected ranking. A "hub" winner here means the two halves ' +
|
||||
'are not connected even though each one works on its own. ' +
|
||||
`got ${hits.map((h) => keyOfId.get(h.id) ?? h.id).join(',')}`,
|
||||
)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// ── C4e: 块数漂移超 20% 后,写入侧必须让中心重新跟上 ──────────────────────
|
||||
//
|
||||
// ★ 这条守的是我在 0a6adff 里自己漏掉的那一半。
|
||||
// 那时的 `#ensureCentroid` 是「没有中心就建一个,有就 return」——
|
||||
// 于是中心永远停在第一次 commit 的快照上,块数翻多少倍它都不动。
|
||||
// 这正是这一整轮反复抓的同一个病:**逻辑写了,但没有被调到**。
|
||||
// `centroidStatus` 里那套 stale 判定(漂移 > 20% + 10 分钟节流 + 异步重建)
|
||||
// 原本在 lib/ 里零生产调用者,不接上的话它只是文件里的一段死代码。
|
||||
//
|
||||
// C4a/b/c 都手工 `rebuildCentroid`,C4d 只验证「首次建」,三条都照不到这里:
|
||||
// 中心存在且过期,与中心不存在,是两个不同的失效形态。
|
||||
test('C4e: a drifted centroid is refreshed by the write path, not left stale forever', async (t) => {
|
||||
// 异步重建靠 setTimeout,用假时钟把 2s 拨过去,免得测试真的睡两秒。
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
|
||||
const { Context } = await import('@deepseek-ai/cordis')
|
||||
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
|
||||
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
|
||||
const { apply: applyMemory } = await import('../lib/index.js')
|
||||
const { mkdtempSync, rmSync } = await import('node:fs')
|
||||
const { tmpdir } = await import('node:os')
|
||||
const { join } = await import('node:path')
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'c4e-'))
|
||||
const ctx = new Context()
|
||||
new SystemPrompt(ctx, {})
|
||||
new ToolRuntime(ctx, {})
|
||||
try {
|
||||
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), embedder: makeEmbedder() })
|
||||
|
||||
// 第一条:写入侧顺手建出中心,此时它只见过 1 条块
|
||||
await mem.commit({ text: CORPUS[0].text })
|
||||
const born = mem.store.loadCentroid(SPACE)
|
||||
assert.ok(born !== undefined, 'precondition: the first commit must build a centroid')
|
||||
const bornCount = born.vectorCount
|
||||
assert.ok(bornCount >= 1, `precondition: the centroid must have counted blocks, got ${bornCount}`)
|
||||
|
||||
// 继续写,直到块数相对建中心时漂移超过 20%
|
||||
for (const block of CORPUS.slice(1)) await mem.commit({ text: block.text })
|
||||
|
||||
// ★ 这里刻意**不**调 centroidStatus。
|
||||
// 它自己就会调度重建 —— 无论写入侧接没接,只要测试里调了它,
|
||||
// 重建就会发生,这条判据就变成了自己证明自己。第一次写这条时
|
||||
// 就是这么写的,M4 变异(退回 0a6adff 的「有中心就 return」)
|
||||
// 居然 6/6 全绿,才把这个问题缴出来。
|
||||
const drift = (CORPUS.length - bornCount) / bornCount
|
||||
assert.ok(
|
||||
drift > 0.2,
|
||||
`precondition: this fixture must drift past the 20% ratio; got ${(drift * 100).toFixed(0)}%`,
|
||||
)
|
||||
|
||||
// 假时钟把重建的 2s 拨过去。
|
||||
// 只有写入侧真的调过 centroidStatus,这里才会有回调可跑。
|
||||
t.mock.timers.tick(2000)
|
||||
|
||||
const healed = mem.store.loadCentroid(SPACE)
|
||||
assert.ok(healed !== undefined, 'C4e: the centroid must still exist after the scheduled rebuild')
|
||||
assert.ok(
|
||||
healed.vectorCount > bornCount,
|
||||
'C4e: a centroid that drifted past the 20% ratio must be rebuilt by the write path. ' +
|
||||
`It is still describing ${healed.vectorCount} blocks while ${CORPUS.length} carry vectors — ` +
|
||||
'that means nothing in lib/ ever consults the stale judgement, so the mean stays frozen ' +
|
||||
'on whatever the first commit happened to see, and centering slowly stops describing ' +
|
||||
'the space it is supposed to correct.',
|
||||
)
|
||||
assert.equal(
|
||||
healed.vectorCount,
|
||||
CORPUS.length,
|
||||
'C4e: the rebuilt centroid must cover every vectored block in this space',
|
||||
)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// ── C4g: 各向同性空间必须完全不校正(isAnomalous 门控)────────────────────
|
||||
//
|
||||
// ★ 这条守的是「门控本身」,不是「校正算得对不对」。
|
||||
// C4a/b/c/d/e 五条用的全是有各向异性的 fixture(C4-0 与 C4d 还专门断言
|
||||
// isAnomalous === true),C4c 是「没有中心行」—— 三条都照不到「有中心、但
|
||||
// 中心判定为各向同性」这个分支。实测把 lib/recall.js 里的
|
||||
// `centroid?.isAnomalous === true &&` 删掉,上述五条加 phase-b-h、phase-b-a
|
||||
// 全部全绿:门控是裸的。
|
||||
//
|
||||
// 数字来源:同一份探针分别打「门控在」与「门控被拆」两个 lib,实测对照,
|
||||
// 不是纸上推的。各向同性空间(8 条正交 one-hot):
|
||||
// meanLength = 0.353553(= 1/sqrt(8)),isAnomalous = false
|
||||
// query = unit(ones) 门控在 → 8 条命中,每条 cosine = 0.3536
|
||||
// 门控拆 → 0 条命中(去均值后各块余弦转负,被 V5 的
|
||||
// 结构性门 `vectorScore <= 0 && symbolHit === 0` 全丢)
|
||||
// 为什么用这条 query:它的方向就是均值方向,去均值后与所有块的夹角都变成钝角,
|
||||
// 落差是二值化的(8 → 0),不依赖阈值也不依赖浮点容差。
|
||||
test('C4g: an isotropic space is left uncorrected — the isAnomalous gate', async (t) => {
|
||||
const { Context } = await import('@deepseek-ai/cordis')
|
||||
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
|
||||
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
|
||||
const { apply: applyMemory } = await import('../lib/index.js')
|
||||
const { mkdtempSync, rmSync } = await import('node:fs')
|
||||
const { tmpdir } = await import('node:os')
|
||||
const { join } = await import('node:path')
|
||||
|
||||
const ISO_DIM = 8
|
||||
const ISO_SPACE = 'c4g:isotropic:test'
|
||||
const ones = new Array(ISO_DIM).fill(1)
|
||||
const queryA = unit(ones) // ≈ 均值方向
|
||||
const queryB = unit([0.5, 0.3, 0.2, 0, 0, 0, 0, 0]) // 三个方向轴的混合
|
||||
const texts = Array.from({ length: ISO_DIM }, (_, i) => `正交块 ${i}`)
|
||||
const vecOf = new Map(texts.map((text, i) => [text, axis(i)]))
|
||||
vecOf.set('Q-A', queryA)
|
||||
vecOf.set('Q-B', queryB)
|
||||
|
||||
const isoEmbedder = {
|
||||
async embedText(text) { return vecOf.get(text) ?? new Array(ISO_DIM).fill(0) },
|
||||
info: () => ({ fingerprint: ISO_SPACE, dimension: ISO_DIM }),
|
||||
}
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'c4g-'))
|
||||
const ctx = new Context()
|
||||
new SystemPrompt(ctx, {})
|
||||
new ToolRuntime(ctx, {})
|
||||
try {
|
||||
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), embedder: isoEmbedder })
|
||||
const keyOf = new Map()
|
||||
for (const text of texts) {
|
||||
const written = await mem.commit({ text })
|
||||
keyOf.set(written.id, text)
|
||||
}
|
||||
// 让写入侧的漂移重建跑完,断言的是「生产路径建出的中心」而不是半成品
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
|
||||
const centroid = mem.store.loadCentroid(mem.space().fingerprint)
|
||||
assert.ok(centroid !== undefined, 'C4g precondition: the production path must still build a centroid')
|
||||
assert.equal(centroid.isAnomalous, false, 'C4g precondition: 8 orthogonal axes are isotropic')
|
||||
assert.ok(
|
||||
Math.abs((centroid.stats?.meanLength ?? 0) - 1 / Math.sqrt(ISO_DIM)) < 1e-6,
|
||||
`C4g precondition: meanLength must be 1/sqrt(8); got ${centroid.stats?.meanLength}`,
|
||||
)
|
||||
assert.equal(centroid.vectorCount, ISO_DIM, 'C4g precondition: every block contributed')
|
||||
|
||||
// ① 方向接近均值的 query:门控在 ⇒ 全部命中,且余弦就是 1/sqrt(8)
|
||||
const hitsA = await recallBlocksHybrid(mem.store, isoEmbedder, 'Q-A', { topK: ISO_DIM, minScore: 0 })
|
||||
t.diagnostic(`C4g mean-like query: n=${hitsA.length} ${hitsA.map((h) => `${Number(h.vectorHit ?? 0).toFixed(4)}`).join(' ')}`)
|
||||
assert.equal(
|
||||
hitsA.length,
|
||||
ISO_DIM,
|
||||
'C4g: an isotropic space must NOT be centred. Removing the isAnomalous gate turns '
|
||||
+ 'every cosine negative here and V5 drops the lot — the count collapses from 8 to 0.',
|
||||
)
|
||||
for (const hit of hitsA) {
|
||||
assert.ok(
|
||||
Math.abs(Number(hit.vectorHit) - 1 / Math.sqrt(ISO_DIM)) < 1e-4,
|
||||
`C4g: each block keeps its raw cosine 1/sqrt(8); got ${hit.vectorHit}`,
|
||||
)
|
||||
}
|
||||
|
||||
// ② 三轴混合的 query:钉死「完全没做去均值」的具体数值
|
||||
const hitsB = await recallBlocksHybrid(mem.store, isoEmbedder, 'Q-B', { topK: 3, minScore: 0 })
|
||||
const byKey = new Map(hitsB.map((h) => [keyOf.get(h.id), Number(h.vectorHit)]))
|
||||
t.diagnostic(`C4g three-axis query: ${[...byKey].map(([k, v]) => `${k}=${v.toFixed(4)}`).join(' ')}`)
|
||||
assert.ok(Math.abs((byKey.get('正交块 0') ?? 0) - 0.8111) < 1e-4, 'C4g: block 0 keeps cosine 0.8111')
|
||||
assert.ok(Math.abs((byKey.get('正交块 1') ?? 0) - 0.4867) < 1e-4, 'C4g: block 1 keeps cosine 0.4867')
|
||||
assert.ok(Math.abs((byKey.get('正交块 2') ?? 0) - 0.3244) < 1e-4, 'C4g: block 2 keeps cosine 0.3244')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// ── C3-k: 召回热路径不得出现计数查询 ────────────────────────────────────────
|
||||
//
|
||||
// `centroidStatus` 的第一行是 `countVectored`(一条 COUNT),而它每轮 pre-step
|
||||
// 都会跑 —— C3-k 明令该查询只能出现在写入侧。实测往 lib/recall.js 里塞一行
|
||||
// `store.countVectored(fingerprint)`,c4 6/6、phase-b-h、phase-b-a 全绿:
|
||||
// 没有判据在守这条边界。所以这里用静态扫描把它钉住。
|
||||
//
|
||||
// ★ 扫描必须自证有效:同一段逻辑对 lib/index.js 跑一遍,必须能找到
|
||||
// `centroidStatus(` —— 否则正则写错时,上面两条「不包含」会永远为真。
|
||||
test('C3-k: the recall hot path must not run a COUNT query', async () => {
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const strip = (relative) => readFileSync(new URL(relative, import.meta.url), 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/\/\/.*$/, ''))
|
||||
.join('\n')
|
||||
|
||||
const recall = strip('../lib/recall.js')
|
||||
assert.ok(
|
||||
!/\bcountVectored\s*\(/.test(recall),
|
||||
'C3-k: lib/recall.js must not call countVectored — that COUNT belongs to the write path',
|
||||
)
|
||||
assert.ok(
|
||||
!/\bcentroidStatus\s*\(/.test(recall),
|
||||
'C3-k: lib/recall.js must not call centroidStatus — it runs countVectored internally, '
|
||||
+ 'and the recall path may only read the centroid (loadCentroid)',
|
||||
)
|
||||
|
||||
// 反向自检:同样的扫描必须能在写入侧看见那次调用,证明扫描不是空转
|
||||
const index = strip('../lib/index.js')
|
||||
assert.ok(
|
||||
/\bcentroidStatus\s*\(/.test(index),
|
||||
'C3-k self-check: the scan must be able to SEE a centroidStatus call — lib/index.js '
|
||||
+ 'calls it on the write path. If this fails the two assertions above prove nothing.',
|
||||
)
|
||||
assert.ok(
|
||||
/\bloadCentroid\s*\(/.test(recall),
|
||||
'C3-k: the recall path reads the centroid through loadCentroid — and only that',
|
||||
)
|
||||
})
|
||||
|
||||
// ── C4h: 单块空间不得被判为各向异性(MIN_SAMPLE)──────────────────────────
|
||||
//
|
||||
// 一条向量的「均值」就是它自己,模长恒为 1.0,必然越过 0.6 阈值 —— 于是
|
||||
// 「只有一条块的空间」会被判成各向异性,然后拿这条块自己去均值。减完是零
|
||||
// 向量,被 recall.js 的 `scoringVector === null` 跳过,唯一那条块就此从
|
||||
// 召回里消失。实测(真时钟、真 commit):第 8 次 commit 之前 centroidCount=1、
|
||||
// meanLength=1.0000,同一库有中心时命中 1 条、无中心时命中 3 条。
|
||||
//
|
||||
// 判据落在两处:判定本身(isAnomalous 必须 false),以及后果(唯一那条块
|
||||
// 必须仍然可召回)。
|
||||
test('C4h: a single-block space is not anisotropic, and its only block stays recallable', async () => {
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
try {
|
||||
const fp = 'c4h:single:test'
|
||||
const only = axis(0)
|
||||
const written = store.putBlock({ text: '唯一的一条块', vector: only, fingerprint: fp })
|
||||
const built = store.rebuildCentroid(fp)
|
||||
|
||||
assert.equal(built.vectorCount, 1, 'C4h precondition: exactly one vector contributed')
|
||||
assert.ok(Math.abs(built.meanLength - 1) < 1e-9, 'C4h precondition: a one-vector mean has norm 1')
|
||||
assert.equal(
|
||||
built.isAnomalous,
|
||||
false,
|
||||
'C4h: a one-vector mean IS that vector (norm 1.0), so the 0.6 test alone always says '
|
||||
+ '"anisotropic". MIN_SAMPLE must refuse to call a single sample a direction — otherwise '
|
||||
+ 'centering subtracts the block from itself and the zero vector is dropped from recall.',
|
||||
)
|
||||
|
||||
const embedder = {
|
||||
async embedText() { return only },
|
||||
info: () => ({ fingerprint: fp, dimension: DIM }),
|
||||
}
|
||||
const hits = await recallBlocksHybrid(store, embedder, '与那条块方向相同的问题', { topK: 3, minScore: 0 })
|
||||
assert.equal(
|
||||
hits.length,
|
||||
1,
|
||||
'C4h: the only block in the space must still be recallable. A count of 0 means the '
|
||||
+ 'single-block "centroid" was applied and its own block was projected onto the zero '
|
||||
+ 'vector.',
|
||||
)
|
||||
assert.equal(hits[0].id, written.id, 'C4h: and it must be the block that was written')
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── C4i: 浮点尘埃不得被当成方向 ─────────────────────────────────────────────
|
||||
//
|
||||
// 一个等于空间均值的块,与均值的差只有浮点残差(~1e-17)。如果把它归一化,
|
||||
// 这堆垃圾会变成一个「单位向量」,再与 query 算出 0.2183 这样的分数 —— 看起来
|
||||
// 像信号,实际是噪声。实测(2 条完全相同的块,query 与块方向不同):
|
||||
//
|
||||
// 原始余弦(无中心) 0.8608
|
||||
// `norm > 0` 时代的输出 0.2183 ← 尘埃被重新归一化
|
||||
// 现在的输出 无命中 ← 尘埃被判定为「没有方向」
|
||||
//
|
||||
// ★ 这条判据钉的是**不变式**,不是某个具体策略:
|
||||
// 「任何返回的命中,其 vectorHit 必须是真实余弦(0.8608),绝不能是尘埃
|
||||
// 推导出来的 0.22 量级」。因此它同时容得下两种未来做法 —— 丢弃这些块
|
||||
// (当前),或对无方差的空间干脆不校正(若以后加上界保护)。
|
||||
// 它唯一不允许的,就是继续拿尘埃打分。
|
||||
test('C4i: floating-point dust is never scored as a direction', async (t) => {
|
||||
const DUP_DIM = 8
|
||||
const fp = 'c4i:duplicate:test'
|
||||
const blockVec = unit([1, 0.2, 0.1, 0, 0, 0, 0, 0])
|
||||
const queryVec = unit([1, 0.9, 0.1, 0, 0, 0, 0, 0])
|
||||
const RAW_COSINE = 0.8608 // measured; see the header
|
||||
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
try {
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
store.putBlock({ text: `重复的块 ${i}`, vector: blockVec, fingerprint: fp })
|
||||
}
|
||||
const built = store.rebuildCentroid(fp)
|
||||
assert.ok(built.meanLength > 0.999, `C4i precondition: identical blocks have a unit mean; got ${built.meanLength}`)
|
||||
assert.equal(built.isAnomalous, true, 'C4i precondition: the fixture must actually enable centring')
|
||||
|
||||
const embedder = {
|
||||
async embedText() { return queryVec },
|
||||
info: () => ({ fingerprint: fp, dimension: DUP_DIM }),
|
||||
}
|
||||
const hits = await recallBlocksHybrid(store, embedder, 'q', { topK: 10, minScore: 0 })
|
||||
t.diagnostic(`C4i duplicate-space hits=${hits.length} ${hits.map((h) => Number(h.vectorHit).toFixed(4)).join(',')}`)
|
||||
|
||||
for (const hit of hits) {
|
||||
assert.ok(
|
||||
Math.abs(Number(hit.vectorHit) - RAW_COSINE) < 1e-3,
|
||||
'C4i: every hit must carry the RAW cosine 0.8608. A value near 0.22 is '
|
||||
+ 'renormalised floating-point dust: the block differs from the space mean '
|
||||
+ 'only by ~1e-17, and turning that into a unit vector manufactures a '
|
||||
+ 'direction that does not exist. Treat it as "no direction" instead.',
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── C4j: stale 时退回未校正(父片令原文)──────────────────────────────────
|
||||
//
|
||||
// 片令写得很清楚:「判 stale 的那一轮仍返回结果(用未校正向量)」。
|
||||
// 召回侧不能自己判 stale —— `centroidStatus` 第一行是 countVectored,C3-k 明令
|
||||
// 它不得进热路径。所以判定在写入侧做,结果落在 centroid 行的 `stats.stale` 上,
|
||||
// 召回侧只读这个标记。
|
||||
//
|
||||
// ★ 这条判据是在修掉一个真 bug 之后补的:最初把标记判断写成
|
||||
// `centroid.stats?.stale !== true`,而标记存的是 JSON 数字 1 —— JS 里
|
||||
// `1 !== true` 恒为真,于是整个 guard 变成 no-op。**当时全套 305 条判据全绿。**
|
||||
// 换成 `!centroid.stats?.stale` 才生效。
|
||||
// 没有这条判据,"写了但永不生效"会一直躺在那儿。
|
||||
//
|
||||
// fixture 用 C4 那个 hub/target 空间:那里的差别是可观察的 ——
|
||||
// 校正时 target 胜(0.9000),不校正时 hub 胜(0.9928,即裸余弦)。
|
||||
test('C4j: a centroid marked stale falls back to uncorrected vectors', async (t) => {
|
||||
const DIM = 8
|
||||
const unit = (v) => { const n = Math.hypot(...v); return n === 0 ? v : v.map((x) => x / n) }
|
||||
const axis = (i) => unit(Array.from({ length: DIM }, (_, k) => (k === i ? 1 : 0)))
|
||||
const BACKBONE = unit([1.0, 0.55, 0.42, 0.30, 0.22, 0.16, 0.11, 0.07])
|
||||
const mk = (eps, ax) => unit(BACKBONE.map((x, i) => x + eps * ax[i]))
|
||||
|
||||
const HUB = { key: 'hub', text: '这段时间一切照旧。', vec: mk(0.02, axis(1)) }
|
||||
const TARGET = { key: 'target', text: '第七号仓库的备用门禁码是 QP47-812。', vec: mk(0.30, axis(7)) }
|
||||
const DISTRACTORS = [0, 2, 3, 4, 5, 6].map((i) => ({ key: `d${i}`, text: `干扰块 ${i}。`, vec: mk(0.30, axis(i)) }))
|
||||
const CORPUS = [HUB, TARGET, ...DISTRACTORS]
|
||||
const QUERY_TEXT = '上次说的那个四位数是什么来着?'
|
||||
const QUERY_VEC = unit(BACKBONE.map((x, i) => x + 0.30 * 0.40 * axis(7)[i]))
|
||||
const byText = new Map(CORPUS.map((b) => [b.text, b.vec]))
|
||||
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
try {
|
||||
const fp = 'c4j:stale-fallback'
|
||||
const keyOf = new Map()
|
||||
for (const b of CORPUS) keyOf.set(store.putBlock({ text: b.text, vector: b.vec, fingerprint: fp }).id, b.key)
|
||||
const built = store.rebuildCentroid(fp)
|
||||
assert.equal(built.isAnomalous, true, 'C4j precondition: the fixture must enable centring')
|
||||
|
||||
const embedder = {
|
||||
async embedText(text) { return text === QUERY_TEXT ? QUERY_VEC : (byText.get(text) ?? new Array(DIM).fill(0)) },
|
||||
info: () => ({ fingerprint: fp, dimension: DIM }),
|
||||
}
|
||||
const top1 = async () => {
|
||||
const hits = await recallBlocksHybrid(store, embedder, QUERY_TEXT, { topK: 3, minScore: 0 })
|
||||
t.diagnostic(`C4j ranking: ${hits.map((h) => `${keyOf.get(h.id)}=${Number(h.vectorHit ?? 0).toFixed(4)}`).join(' ')}`)
|
||||
return { key: keyOf.get(hits[0]?.id), score: Number(hits[0]?.vectorHit ?? 0) }
|
||||
}
|
||||
|
||||
const fresh = await top1()
|
||||
assert.equal(fresh.key, 'target', 'C4j: with a usable centroid the target wins')
|
||||
|
||||
// The write path's verdict, persisted on the row.
|
||||
assert.equal(store.markCentroidStale(fp), true, 'C4j: the flag must actually be written')
|
||||
const stale = await top1()
|
||||
assert.equal(
|
||||
stale.key,
|
||||
'hub',
|
||||
'C4j: a round that judges the centroid stale must fall back to UNCORRECTED vectors '
|
||||
+ '(the raw cosine then puts the hub first). If this still says "target", the flag '
|
||||
+ 'is being ignored — the exact bug that a strict `!== true` comparison produced '
|
||||
+ 'while the whole suite stayed green.',
|
||||
)
|
||||
assert.ok(Math.abs(stale.score - 0.9928) < 1e-3, `C4j: and it must be the raw cosine; got ${stale.score}`)
|
||||
|
||||
store.rebuildCentroid(fp)
|
||||
const healed = await top1()
|
||||
assert.equal(healed.key, 'target', 'C4j: a rebuild clears the flag and centring returns')
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── C4k: 落标记这件事必须是写入侧自己干的,不是测试替它干 ───────────────────
|
||||
//
|
||||
// C4j 里有一行 `store.markCentroidStale(fp)` —— 那是判据**手工**打的标记,它只
|
||||
// 证明「召回侧会尊重标记」,不证明「生产路径会打这个标记」。
|
||||
//
|
||||
// 实测:把 `lib/index.js` 里那行 `if (status?.stale === true) this.store
|
||||
// .markCentroidStale(...)` 整个删掉,**全套 306 条判据全绿**。也就是说这条接线
|
||||
// 此前没有任何判据守着 —— 与 W8(saveSpaceCentroid 零生产调用点)、C4d(中心
|
||||
// 靠测试手工 rebuildCentroid)是同一个病:手工喂进去的输入,证明不了生产路径。
|
||||
//
|
||||
// 这条判据只用生产入口:`apply()` + `commit()`,自己不打任何标记。
|
||||
test('C4k: the write path itself marks a drifted centroid stale', async () => {
|
||||
const { Context } = await import('@deepseek-ai/cordis')
|
||||
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
|
||||
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
|
||||
const { apply: applyMemory } = await import('../lib/index.js')
|
||||
const { mkdtempSync, rmSync } = await import('node:fs')
|
||||
const { tmpdir } = await import('node:os')
|
||||
const { join } = await import('node:path')
|
||||
|
||||
const DIM = 4
|
||||
const dir = mkdtempSync(join(tmpdir(), 'c4k-'))
|
||||
const ctx = new Context()
|
||||
new SystemPrompt(ctx, {})
|
||||
new ToolRuntime(ctx, {})
|
||||
try {
|
||||
const mem = applyMemory(ctx, {
|
||||
path: join(dir, 'm.db'),
|
||||
embedder: {
|
||||
async embedText() { return unit([1, 0.1, 0, 0]) },
|
||||
info: () => ({ fingerprint: 'c4k:write-marks', dimension: DIM }),
|
||||
},
|
||||
})
|
||||
const fp = mem.space().fingerprint
|
||||
|
||||
// Build a centroid, then let the scheduled rebuild land so the count is settled.
|
||||
for (let i = 0; i < 8; i += 1) await mem.commit({ text: `块 ${i}` })
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
const settled = mem.store.loadCentroid(fp)
|
||||
assert.ok(settled !== undefined, 'C4k precondition: a centroid must exist')
|
||||
assert.equal(
|
||||
settled.stats?.stale,
|
||||
undefined,
|
||||
'C4k precondition: a freshly rebuilt centroid carries no stale flag',
|
||||
)
|
||||
|
||||
// Drift it past the 20% ratio through the production write path only.
|
||||
for (let i = 8; i < 11; i += 1) await mem.commit({ text: `块 ${i}` })
|
||||
|
||||
const drifted = mem.store.loadCentroid(fp)
|
||||
assert.equal(
|
||||
drifted.stats?.stale,
|
||||
1,
|
||||
'C4k: the WRITE path must persist the stale verdict — the recall side is not '
|
||||
+ 'allowed to judge it (C3-k), so if this flag is missing, recall keeps '
|
||||
+ 'centring with a mean that no longer describes the space. Deleting that one '
|
||||
+ 'line in #ensureCentroid left all 306 criteria green, which is why this '
|
||||
+ 'criterion exists.',
|
||||
)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
390
test/phase-b-c5-reasons.test.js
Normal file
390
test/phase-b-c5-reasons.test.js
Normal file
@ -0,0 +1,390 @@
|
||||
/**
|
||||
* C5 · 「为什么不校正」必须可区分。
|
||||
*
|
||||
* ## 这条判据要解决的病
|
||||
*
|
||||
* C3 的 `centroidStatus` 早就把四种失效原因都算出来了,但对外只给
|
||||
* `available: false` 一个结论。于是:
|
||||
*
|
||||
* · `no_centroid` 与 `dim_mismatch` 都是 `stale: null`
|
||||
* · `isotropic` 与健康空间都 `stale: false`,只差 available
|
||||
*
|
||||
* 对「能不能校正」这个问题够用;对「为什么语义召回一直不校正」完全不够。
|
||||
* 运维看到的是一句 `available: false`,四种完全不同的处置方式(建中心 /
|
||||
* 换维度 / 等重建 / 这个空间本来就没有公共方向)被压成同一个症状。
|
||||
*
|
||||
* ## 判据形态
|
||||
*
|
||||
* 每一种原因一个 fixture,直接断 `reason` 这个值本身 —— 不断 available、
|
||||
* 不断 stale,因为它们本来就是多义的。同时断「available 与 reason 是否自洽」,
|
||||
* 防止有人把 reason 写成与结论脱节的装饰。
|
||||
*
|
||||
* ★ 所有调用都注入 `schedule: () => {}`:`centroidStatus` 判 stale 时会调度
|
||||
* 重建,那是副作用。C4e 第一版就因为没隔离它而自证过 —— 判据必须只看事实,
|
||||
* 不触发被测行为。
|
||||
*
|
||||
* @module dsh-memory/test/phase-b-c5-reasons
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { BlockStore } from '../lib/store.js'
|
||||
|
||||
const DIM = 4
|
||||
const unit = (v) => { const n = Math.hypot(...v); return n === 0 ? v : v.map((x) => x / n) }
|
||||
const noSchedule = () => {}
|
||||
|
||||
/** 每条判据都用它:一次调用,注入空调度,避免副作用串味。 */
|
||||
const statusOf = (store, fp, queryDim = DIM) =>
|
||||
store.centroidStatus(fp, { queryDim, schedule: noSchedule })
|
||||
|
||||
test('C5: a space with blocks but no centroid row reports no_centroid', () => {
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
try {
|
||||
const fp = 'c5:no-centroid'
|
||||
for (let i = 0; i < 3; i += 1) store.putBlock({ text: `a${i}`, vector: unit([1, i * 0.01, 0, 0]), fingerprint: fp })
|
||||
|
||||
const s = statusOf(store, fp)
|
||||
assert.equal(s.reason, 'no_centroid', 'a missing row is a missing row, not a dimension problem')
|
||||
assert.equal(s.available, false)
|
||||
assert.equal(s.dim, null, 'and it must not invent a dimension')
|
||||
assert.equal(s.vectorCount, null)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('C5: a centroid of the wrong width reports dim_mismatch', () => {
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
try {
|
||||
const fp = 'c5:dim-mismatch'
|
||||
for (let i = 0; i < 3; i += 1) store.putBlock({ text: `b${i}`, vector: unit([1, 0.01 * i, 0, 0]), fingerprint: fp })
|
||||
store.rebuildCentroid(fp)
|
||||
|
||||
const s = statusOf(store, fp, 8)
|
||||
assert.equal(s.reason, 'dim_mismatch', 'a centroid exists and is readable — it is simply the wrong width')
|
||||
assert.equal(s.available, false)
|
||||
assert.equal(s.dim, DIM, 'the stored width must still be reported, so the operator can see the mismatch')
|
||||
assert.notEqual(s.dim, 8)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('C5: a centroid row with zero samples reports zero_sample', () => {
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
try {
|
||||
const fp = 'c5:zero-sample'
|
||||
// A row written by an older build / a migration: present, but built from nothing.
|
||||
store.saveCentroid({
|
||||
fingerprint: fp, dim: DIM, vectorCount: 0,
|
||||
mean: new Array(DIM).fill(0), isAnomalous: false,
|
||||
stats: { source: 'migration' },
|
||||
})
|
||||
|
||||
const s = statusOf(store, fp)
|
||||
assert.equal(s.reason, 'zero_sample', 'a centroid built from zero vectors is its own cause')
|
||||
assert.equal(s.available, false)
|
||||
assert.equal(s.vectorCount, 0)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('C5: a centroid that drifted past the ratio reports stale', () => {
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
try {
|
||||
const fp = 'c5:stale'
|
||||
for (let i = 0; i < 8; i += 1) store.putBlock({ text: `c${i}`, vector: unit([1, 0.001 * i, 0, 0]), fingerprint: fp })
|
||||
store.rebuildCentroid(fp)
|
||||
for (let i = 8; i < 12; i += 1) store.putBlock({ text: `c${i}`, vector: unit([1, 0.001 * i, 0, 0]), fingerprint: fp })
|
||||
|
||||
const s = statusOf(store, fp)
|
||||
assert.equal(s.reason, 'stale', 'the centroid is usable in principle, it just no longer describes the space')
|
||||
assert.equal(s.available, false)
|
||||
assert.equal(s.stale, true)
|
||||
assert.ok(s.delta > 0.2, `precondition: the drift must exceed the ratio; got ${s.delta}`)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('C5: an isotropic space reports isotropic, not stale', () => {
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
try {
|
||||
const fp = 'c5:isotropic'
|
||||
for (const [i, v] of [[1, 0, 0, 0], [0, 1, 0, 0], [-1, 0, 0, 0], [0, -1, 0, 0]].entries()) {
|
||||
store.putBlock({ text: `d${i}`, vector: v, fingerprint: fp })
|
||||
}
|
||||
store.rebuildCentroid(fp)
|
||||
|
||||
const s = statusOf(store, fp)
|
||||
assert.equal(s.reason, 'isotropic', 'four orthogonal axes have no common direction to remove — '
|
||||
+ 'this is not a staleness problem and must not be reported as one')
|
||||
assert.equal(s.available, false)
|
||||
assert.equal(s.stale, false, 'isotropic is a permanent property of the space, not a transient state')
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('C5: a healthy space reports ok, and only ok carries available=true', () => {
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
try {
|
||||
const fp = 'c5:healthy'
|
||||
for (let i = 0; i < 8; i += 1) store.putBlock({ text: `e${i}`, vector: unit([1, 0.001 * i, 0, 0]), fingerprint: fp })
|
||||
store.rebuildCentroid(fp)
|
||||
|
||||
const s = statusOf(store, fp)
|
||||
assert.equal(s.reason, 'ok')
|
||||
assert.equal(s.available, true)
|
||||
assert.equal(s.stale, false)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('C5: reason and available never disagree', () => {
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
try {
|
||||
// Build every branch at once and check the invariant holds in all of them.
|
||||
const cases = []
|
||||
const mk = (fp, n, opts = {}) => {
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
store.putBlock({ text: `${fp}-${i}`, vector: unit([1, 0.001 * i, 0, 0]), fingerprint: fp })
|
||||
}
|
||||
if (opts.build !== false) store.rebuildCentroid(fp)
|
||||
}
|
||||
|
||||
mk('c5:i1', 3, { build: false }) // no_centroid
|
||||
mk('c5:i2', 3) // ok / dim_mismatch via queryDim
|
||||
cases.push([statusOf(store, 'c5:i1'), 'no_centroid'])
|
||||
cases.push([statusOf(store, 'c5:i2'), 'ok'])
|
||||
cases.push([statusOf(store, 'c5:i2', 8), 'dim_mismatch'])
|
||||
|
||||
for (const [s, expected] of cases) {
|
||||
assert.equal(s.reason, expected)
|
||||
assert.equal(
|
||||
s.available,
|
||||
s.reason === 'ok',
|
||||
`C5: available must be true for exactly one reason (ok); got reason=${s.reason} available=${s.available}`,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('C5: centroidRecallState keeps its stale:null projection but carries the reason', () => {
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
try {
|
||||
const fp = 'c5:recall-projection'
|
||||
for (let i = 0; i < 8; i += 1) store.putBlock({ text: `f${i}`, vector: unit([1, 0.001 * i, 0, 0]), fingerprint: fp })
|
||||
store.rebuildCentroid(fp)
|
||||
for (let i = 8; i < 12; i += 1) store.putBlock({ text: `f${i}`, vector: unit([1, 0.001 * i, 0, 0]), fingerprint: fp })
|
||||
|
||||
const projection = store.centroidRecallState(fp, { queryDim: DIM, schedule: noSchedule })
|
||||
assert.equal(projection.stale, null, 'the recall-facing projection keeps hiding staleness from the scorer')
|
||||
assert.equal(projection.reason, 'stale', 'but the reason still travels, so an operator can see why')
|
||||
assert.equal(projection.available, false)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── C5 的出口:reason 必须能被运维读到,而且读的时候不许有副作用 ─────────────
|
||||
//
|
||||
// 上面八条都在 store 层证明「六种原因可区分」。但区分出来没有出口,等于只做完
|
||||
// 一半:`centroid` 这一项给的是 `loadCentroid` 的原始行,它说得出「中心是什么」,
|
||||
// 说不出「为什么没在校正」。全仓曾经没有任何地方读 `centroidStatus` 的返回值。
|
||||
//
|
||||
// 现在它从 `stats().centroid_state` 出去。两条要求:
|
||||
// 1. reason 与 store 层判定一致;
|
||||
// 2. **纯读** —— `centroidStatus` 判 stale 时会登记一次重建,而 stats 是只读
|
||||
// 接口,不许因此产生写副作用。用空调度器把它按住,`rebuildScheduled`
|
||||
// 在这里恒为 false,这正是纯读该有的样子。
|
||||
test('C5: stats() exposes the reason without scheduling a rebuild', async () => {
|
||||
const { Context } = await import('@deepseek-ai/cordis')
|
||||
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
|
||||
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
|
||||
const { apply: applyMemory } = await import('../lib/index.js')
|
||||
const { mkdtempSync, rmSync } = await import('node:fs')
|
||||
const { tmpdir } = await import('node:os')
|
||||
const { join } = await import('node:path')
|
||||
|
||||
const unit = (v) => { const n = Math.hypot(...v); return n === 0 ? v : v.map((x) => x / n) }
|
||||
const dir = mkdtempSync(join(tmpdir(), 'c5-stats-'))
|
||||
const ctx = new Context()
|
||||
new SystemPrompt(ctx, {})
|
||||
new ToolRuntime(ctx, {})
|
||||
try {
|
||||
// ── fresh space: no centroid yet ──────────────────────────────────────
|
||||
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), embedder: {
|
||||
async embedText() { return unit([1, 0.1, 0, 0]) },
|
||||
info: () => ({ fingerprint: 'c5:stats:test', dimension: 4 }),
|
||||
} })
|
||||
assert.equal(
|
||||
mem.stats().centroid_state.reason,
|
||||
'no_centroid',
|
||||
'C5: stats() must say WHY centring is off, not merely that no row exists',
|
||||
)
|
||||
|
||||
// ── after writes: healthy ─────────────────────────────────────────────
|
||||
for (let i = 0; i < 8; i += 1) await mem.commit({ text: `块 ${i}` })
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000)) // let the drift rebuild land
|
||||
let s = mem.stats()
|
||||
assert.equal(s.centroid_state.reason, 'ok', 'a built, current centroid reports ok')
|
||||
assert.equal(s.centroid_state.available, true)
|
||||
// Both surfaces coexist: `centroid` is the raw row, `centroid_state` is the
|
||||
// verdict. Asserting they are the SAME object would be a tautology; the
|
||||
// meaningful claim is that the raw row still carries what it always did
|
||||
// (dim / mean) while the verdict carries the reason.
|
||||
assert.equal(typeof s.centroid?.dim, 'number', 'the raw centroid row is still reported')
|
||||
assert.ok(Array.isArray(s.centroid_state === null ? null : s.centroid?.mean), 'and its mean is intact')
|
||||
assert.equal(typeof s.centroid_state.reason, 'string', 'the verdict names a reason')
|
||||
|
||||
// ── drift past 20%: stale, and stats() must NOT register a rebuild ────
|
||||
for (let i = 8; i < 16; i += 1) await mem.commit({ text: `块 ${i}` })
|
||||
s = mem.stats()
|
||||
assert.equal(s.centroid_state.reason, 'stale', 'the drift is what the operator needs to see')
|
||||
assert.equal(s.centroid_state.stale, true)
|
||||
assert.equal(
|
||||
s.centroid_state.rebuildScheduled,
|
||||
false,
|
||||
'C5: stats() is a READ. If it schedules a rebuild it has become a write path, '
|
||||
+ 'and every operator polling stats() would keep the rebuild loop alive.',
|
||||
)
|
||||
assert.ok(s.centroid_state.delta > 0.2, `precondition: the drift must exceed the ratio; got ${s.centroid_state.delta}`)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// ── C5 的最后一段:reason 必须出现在**模型真正调用的那个工具**的返回里 ────────
|
||||
//
|
||||
// 前九条分别证明:六种原因可区分(store 层)、stats() 带 reason 且纯读
|
||||
// (plugin 层)。但 dsh 里的模型不会去读 `mem.stats()` —— 它调工具。
|
||||
//
|
||||
// 这个缺口在本轮出现过三次同款:P1 的 queryVector 算完丢掉、D2 的计数器算完
|
||||
// 不打印、C5 的 reason 算完没人读。每一次都是「值存在、判据绿、运维看不见」。
|
||||
// 工具面是第四个可能断的环节,而且是最靠外的一个:handler 改成挑字段返回,
|
||||
// 前面所有判据都会继续绿。
|
||||
//
|
||||
// 所以这条直接走 `ctx.tools.execute` —— 与 phase-b-h 里调工具的方式一致,
|
||||
// signal 必须是真实 AbortSignal(ToolRuntime 内部会读 callerSignal.aborted)。
|
||||
test('C5: the stats TOOL returns the reason, not just the service method', async () => {
|
||||
const { Context } = await import('@deepseek-ai/cordis')
|
||||
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
|
||||
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
|
||||
const { apply: applyMemory } = await import('../lib/index.js')
|
||||
const { mkdtempSync, rmSync } = await import('node:fs')
|
||||
const { tmpdir } = await import('node:os')
|
||||
const { join } = await import('node:path')
|
||||
|
||||
const callStats = async (ctx) => {
|
||||
const result = await ctx.tools.execute({
|
||||
callId: 'c5-stats',
|
||||
name: 'stablemem_stats',
|
||||
arguments: {},
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
const text = result?.content?.[0]?.text ?? result?.value ?? result
|
||||
return typeof text === 'string' ? JSON.parse(text) : text
|
||||
}
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'c5-tool-'))
|
||||
const ctx = new Context()
|
||||
new SystemPrompt(ctx, {})
|
||||
new ToolRuntime(ctx)
|
||||
const mem = applyMemory(ctx, {
|
||||
path: join(dir, 'm.db'),
|
||||
embedder: {
|
||||
async embedText() { return unit([1, 0.1, 0, 0]) },
|
||||
info: () => ({ fingerprint: 'c5:tool:test', dimension: 4 }),
|
||||
},
|
||||
})
|
||||
try {
|
||||
const empty = await callStats(ctx)
|
||||
assert.ok(
|
||||
'centroid_state' in empty,
|
||||
'C5: the tool must expose centroid_state — that is the surface the model and '
|
||||
+ 'the operator actually call. Every other criterion can stay green while a '
|
||||
+ 'handler that cherry-picks fields drops it.',
|
||||
)
|
||||
assert.equal(empty.centroid_state.reason, 'no_centroid')
|
||||
|
||||
for (let i = 0; i < 8; i += 1) await mem.commit({ text: `块 ${i}` })
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
const healthy = await callStats(ctx)
|
||||
assert.equal(healthy.centroid_state.reason, 'ok', 'and the healthy verdict travels too')
|
||||
assert.equal(healthy.centroid_state.available, true)
|
||||
assert.ok('last_recall' in healthy, 'precondition: sibling fields still present')
|
||||
|
||||
for (let i = 8; i < 16; i += 1) await mem.commit({ text: `块 ${i}` })
|
||||
const drifted = await callStats(ctx)
|
||||
assert.equal(drifted.centroid_state.reason, 'stale')
|
||||
assert.equal(
|
||||
drifted.centroid_state.rebuildScheduled,
|
||||
false,
|
||||
'C5: calling the tool must not schedule a rebuild — a read-only surface that '
|
||||
+ 'mutates would let a polling operator drive the rebuild loop.',
|
||||
)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// ── C5: 样本不足必须与「本来就没方向」分开 ──────────────────────────────────
|
||||
//
|
||||
// MIN_SAMPLE 让「一条块」的空间不再判为各向异性(一条块不构成方向),于是它
|
||||
// 落进了 `isotropic` 分支 —— 而这两件事的修法是相反的:
|
||||
//
|
||||
// isotropic 这个空间永远不会有公共方向,别指望校正
|
||||
// too_few_samples 再写几条块就好了
|
||||
//
|
||||
// 报错哪一种不是措辞问题:C5 存在的全部理由,就是让运维知道该往哪儿走。
|
||||
//
|
||||
// ★ 这个洞是我自己捅的:MIN_SAMPLE 是我加的,`too_few_samples` 是补上之后才
|
||||
// 成立的,所以这条判据同时钉住两件事 —— 1 条块的空间报 too_few_samples,
|
||||
// 以及 2 条块的空间(够 MIN_SAMPLE)不会误报成样本不足。
|
||||
test('C5: a one-vector space reports too_few_samples, not isotropic', () => {
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
try {
|
||||
// ── one vector: undersampled ─────────────────────────────────────────
|
||||
const fp1 = 'c5:one-vector'
|
||||
store.putBlock({ text: '唯一一条', vector: unit([1, 0, 0, 0]), fingerprint: fp1 })
|
||||
const built = store.rebuildCentroid(fp1)
|
||||
assert.equal(built.vectorCount, 1)
|
||||
assert.equal(
|
||||
built.isAnomalous,
|
||||
false,
|
||||
'precondition: MIN_SAMPLE keeps one sample from being called a direction',
|
||||
)
|
||||
|
||||
const s1 = statusOf(store, fp1)
|
||||
assert.equal(
|
||||
s1.reason,
|
||||
'too_few_samples',
|
||||
'C5: "not enough blocks yet" and "this space has no common direction" lead to '
|
||||
+ 'opposite actions. Reporting a one-vector space as isotropic tells the operator '
|
||||
+ 'to give up instead of to write more.',
|
||||
)
|
||||
assert.equal(s1.available, false)
|
||||
|
||||
// ── two vectors: enough to be judged, and NOT reported as undersampled ─
|
||||
const fp2 = 'c5:two-vectors'
|
||||
store.putBlock({ text: 'a', vector: unit([1, 0, 0, 0]), fingerprint: fp2 })
|
||||
store.putBlock({ text: 'b', vector: unit([1, 0.001, 0, 0]), fingerprint: fp2 })
|
||||
store.rebuildCentroid(fp2)
|
||||
const s2 = statusOf(store, fp2)
|
||||
assert.notEqual(
|
||||
s2.reason,
|
||||
'too_few_samples',
|
||||
'precondition: MIN_SAMPLE is 2, so two samples must clear the undersampled branch',
|
||||
)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
265
test/phase-b-g-static.test.js
Normal file
265
test/phase-b-g-static.test.js
Normal file
@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Phase B · group G — static (source-text) guards.
|
||||
*
|
||||
* ★ This file deliberately imports **nothing** from `lib/`.
|
||||
*
|
||||
* G2b and G5 inspect source text. If they lived beside the behavioural guards,
|
||||
* they would be imported through `lib/index.js` and a broken build would crash
|
||||
* the whole file — producing "load failed" instead of the actual finding
|
||||
* ("a disallowed dependency was added"). A guard that cannot report its own
|
||||
* verdict is not a guard. Keeping this file dependency-free means it survives a
|
||||
* broken `lib/` and names the offender.
|
||||
*
|
||||
* @module dsh-memory/test/phase-b-g-static
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
const LIB = new URL('../lib/', import.meta.url).pathname
|
||||
|
||||
/** Every .js file under lib/, recursively. */
|
||||
function libSources() {
|
||||
const out = []
|
||||
const walk = (dir) => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name)
|
||||
if (entry.isDirectory()) walk(full)
|
||||
else if (entry.name.endsWith('.js')) out.push(full)
|
||||
}
|
||||
}
|
||||
walk(LIB)
|
||||
return out
|
||||
}
|
||||
|
||||
const rel = (file) => file.replace(LIB, 'lib/')
|
||||
|
||||
test('G2b: lib/ imports only node: builtins and cordis/dsh peers', () => {
|
||||
// A new runtime dependency (an ONNX binding, an HTTP client, a vector DB) shows
|
||||
// up here before it shows up in a deployment.
|
||||
// `dsh-agent-loop` joined this list with lib/loop.js: the loop subclass has to
|
||||
// extend the harness's own AgentLoop, and that package is declared as a peer in
|
||||
// package.json exactly like dsh-tools. It is still a first-party harness
|
||||
// package, not the kind of new runtime dependency (a binding, an HTTP client,
|
||||
// a vector DB) this guard exists to catch.
|
||||
const allowed = /^(node:|@deepseek-ai\/(cordis|dsh-llm|dsh-system-prompt|dsh-tools|dsh-session|dsh-scope|dsh-agent-loop)|\.\/|\.\.\/)/
|
||||
const offenders = []
|
||||
for (const file of libSources()) {
|
||||
const text = readFileSync(file, 'utf8')
|
||||
for (const match of text.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)) {
|
||||
const spec = match[1]
|
||||
if (!allowed.test(spec)) offenders.push(`${file.replace(LIB, 'lib/')}: ${spec}`)
|
||||
}
|
||||
for (const match of text.matchAll(/^\s*(?:const|let)\s+\w+\s*=\s*require\(\s*['"]([^'"]+)['"]/gm)) {
|
||||
const spec = match[1]
|
||||
if (!allowed.test(spec)) offenders.push(`${file.replace(LIB, 'lib/')}: require(${spec})`)
|
||||
}
|
||||
}
|
||||
assert.deepEqual(offenders, [], `disallowed runtime imports:\n${offenders.join('\n')}`)
|
||||
})
|
||||
|
||||
// ── G5: provider identifiers confined to one file ────────────────────────
|
||||
test('G5: provider-specific identifiers may appear in exactly one lib file', () => {
|
||||
// The criterion is not "never mention http" — the transport has to live
|
||||
// somewhere. It is "nowhere EXCEPT the adapter", so swapping the transport
|
||||
// cannot require edits scattered across recall/scoring/storage.
|
||||
// The only place allowed to know a transport. Kept as the PREFIX actually
|
||||
// compared against `rel` — an earlier version compared against
|
||||
// `ADAPTER.slice(4)` (== 'embed/'), which never matched 'lib/embed/...', so the
|
||||
// allowlist silently did nothing. It stayed green only because lib/embed/ did
|
||||
// not exist yet; the moment a legitimate adapter landed it would have flagged
|
||||
// that adapter's own `fetch`. See G5†.
|
||||
const ADAPTER = 'lib/embed/'
|
||||
// Only CODE counts. Comments legitimately say "no endpoint" and "edge endpoint",
|
||||
// and a scan that cannot tell prose from code produces false positives that
|
||||
// get the whole criterion disabled. Strip comments, then scan.
|
||||
const stripComments = (text) =>
|
||||
text
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
|
||||
.replace(/(^|[^:])\/\/.*$/gm, '$1') // line comments (not http://)
|
||||
|
||||
const forbidden = [
|
||||
/\bfetch\s*\(/, // outbound call
|
||||
/Authorization/i, // auth header
|
||||
/\bBearer\b/,
|
||||
/https?:\/\//, // literal URL
|
||||
/\bmodel_dir\b/, // ONNX provider option
|
||||
/\bapi_key\b/i, // provider credential
|
||||
]
|
||||
const offenders = []
|
||||
for (const file of libSources()) {
|
||||
const rel = file.replace(LIB, 'lib/')
|
||||
if (rel.startsWith(ADAPTER)) continue // allowed area
|
||||
const text = stripComments(readFileSync(file, 'utf8'))
|
||||
for (const pattern of forbidden) {
|
||||
const hit = text.match(pattern)
|
||||
if (hit !== null) offenders.push(`${rel}: ${hit[0]}`)
|
||||
}
|
||||
}
|
||||
assert.deepEqual(offenders, [], `transport leaked outside ${ADAPTER}:\n${offenders.join('\n')}`)
|
||||
})
|
||||
|
||||
// ── mutations ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// These do not mutate lib/ on disk (a test must not edit the tree it is
|
||||
// verifying). Instead each one re-runs the SAME detection logic over a synthetic
|
||||
// file list, so the assertion is "the rule as written would have caught this".
|
||||
// G5† is special: it re-implements the buggy prefix and proves the buggy version
|
||||
// passes a legitimate adapter, which is exactly how the real bug stayed hidden.
|
||||
|
||||
/** The detection core, parameterised by the file list and the allowlist test. */
|
||||
function detect(files, isAllowed) {
|
||||
const forbidden = [/\bfetch\s*\(/, /Authorization/i, /\bBearer\b/, /https?:\/\//, /\bmodel_dir\b/, /\bapi_key\b/i]
|
||||
const stripComments = (text) =>
|
||||
text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
|
||||
const offenders = []
|
||||
for (const { rel, text } of files) {
|
||||
if (isAllowed(rel)) continue
|
||||
const code = stripComments(text)
|
||||
for (const pattern of forbidden) {
|
||||
const hit = code.match(pattern)
|
||||
if (hit !== null) offenders.push(`${rel}: ${hit[0]}`)
|
||||
}
|
||||
}
|
||||
return offenders
|
||||
}
|
||||
|
||||
const LEGIT_ADAPTER = {
|
||||
rel: 'lib/embed/http.js',
|
||||
text: `export async function embedText(t) {
|
||||
const r = await fetch('https://example.invalid/embed', {
|
||||
headers: { Authorization: 'Bearer k' },
|
||||
})
|
||||
return (await r.json()).embedding
|
||||
}`,
|
||||
}
|
||||
|
||||
const VIOLATING_STORE = {
|
||||
rel: 'lib/store.js',
|
||||
text: `const r = await fetch('https://x/')`,
|
||||
}
|
||||
|
||||
test('G5†: the OLD prefix (ADAPTER.slice(4)) fails a legitimate adapter — the real bug', () => {
|
||||
const buggy = (rel) => rel.startsWith('lib/embed/'.slice(4)) // == 'embed/' → never true
|
||||
const offenders = detect([LEGIT_ADAPTER], buggy)
|
||||
// The buggy rule flags the adapter's own fetch: a guard that cries wolf.
|
||||
assert.equal(
|
||||
offenders.length > 0,
|
||||
true,
|
||||
'sanity: the buggy prefix must FAIL to exempt the adapter (that was the bug)',
|
||||
)
|
||||
// And that is why it had to be fixed: the correct rule exempts it.
|
||||
const fixed = (rel) => rel.startsWith('lib/embed/')
|
||||
assert.deepEqual(detect([LEGIT_ADAPTER], fixed), [], 'the fixed rule exempts the adapter')
|
||||
})
|
||||
|
||||
test('G5b†: a fetch planted in lib/store.js must be reported, naming store.js', () => {
|
||||
const fixed = (rel) => rel.startsWith('lib/embed/')
|
||||
const offenders = detect([LEGIT_ADAPTER, VIOLATING_STORE], fixed)
|
||||
// One line can trip several patterns (`fetch(` AND `https://`), so assert on
|
||||
// WHICH FILE is named, not on the number of matches.
|
||||
assert.ok(offenders.length >= 1, `expected at least one offender, got ${JSON.stringify(offenders)}`)
|
||||
assert.ok(
|
||||
offenders.every((o) => o.startsWith('lib/store.js: ')),
|
||||
`every offender must name store.js; got ${JSON.stringify(offenders)}`,
|
||||
)
|
||||
assert.ok(
|
||||
!offenders.some((o) => o.startsWith('lib/embed/')),
|
||||
'the legitimate adapter must NOT be reported',
|
||||
)
|
||||
})
|
||||
|
||||
test('G2b†: a disallowed runtime import must be reported with file and package', () => {
|
||||
const allowed = /^(node:|@deepseek-ai\/(cordis|dsh-llm|dsh-system-prompt|dsh-tools|dsh-session|dsh-scope)|\.\/|\.\.\/)/
|
||||
const scanImports = (files) => {
|
||||
const out = []
|
||||
for (const { rel, text } of files) {
|
||||
for (const m of text.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)) {
|
||||
if (!allowed.test(m[1])) out.push(`${rel}: ${m[1]}`)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
const offenders = scanImports([
|
||||
{ rel: 'lib/store.js', text: "import * as ort from 'onnxruntime-node'" },
|
||||
{ rel: 'lib/recall.js', text: "import { createHash } from 'node:crypto'" },
|
||||
])
|
||||
assert.deepEqual(offenders, ['lib/store.js: onnxruntime-node'])
|
||||
})
|
||||
|
||||
// ── the checkpoint runner must not be able to pass with zero checkpoints ──
|
||||
//
|
||||
// Replacing the old README-count guard silently removed its second assertion:
|
||||
// "scripts/checkpoints.js declares at least one checkpoint". Nothing guards that
|
||||
// any more, and the failure is invisible — stubbing the helper yields
|
||||
// `0/0 checkpoints passed`, exit code 0, and every test still green.
|
||||
//
|
||||
// Two layers, because they catch different causes:
|
||||
// · the runner itself exits non-zero when `results.length === 0` (rename,
|
||||
// stub, conditional skip); and
|
||||
// · this assertion proves the runner still contains that guard, because an
|
||||
// early `process.exit(0)` bypasses any in-script check entirely.
|
||||
test('scripts/checkpoints.js registers checkpoints AND refuses to pass with zero', () => {
|
||||
const root = new URL('..', import.meta.url).pathname
|
||||
const script = readFileSync(join(root, 'scripts', 'checkpoints.js'), 'utf8')
|
||||
|
||||
// 1. It actually registers something. Guards against a rename of the helper.
|
||||
const declared = [...script.matchAll(/await checkpoint\('/g)].length
|
||||
assert.ok(
|
||||
declared > 0,
|
||||
'scripts/checkpoints.js declares no `await checkpoint(...)` calls — did the helper get renamed?',
|
||||
)
|
||||
|
||||
// 2. It refuses to report success when nothing ran. This is the guard that a
|
||||
// stub or a conditional skip trips; an early `process.exit(0)` can only be
|
||||
// caught here, by asserting the guard exists.
|
||||
assert.match(
|
||||
script,
|
||||
/if \(results\.length === 0\)/,
|
||||
'the runner must contain an explicit zero-results guard',
|
||||
)
|
||||
assert.match(
|
||||
script,
|
||||
/results\.length === 0[\s\S]{0,200}process\.exitCode = 1/,
|
||||
'the zero-results guard must set a non-zero exit code',
|
||||
)
|
||||
})
|
||||
|
||||
// ── README: no unguarded counts ───────────────────────────────────────────
|
||||
//
|
||||
// This used to assert that README's test/checkpoint counts matched reality. That
|
||||
// made the README a second source of truth which every test addition had to
|
||||
// update — and it drifted five times. The decision was to remove the counts
|
||||
// instead of guarding them: a number nobody verifies reads as precise while being
|
||||
// wrong, and the guard cost more than the number was worth.
|
||||
//
|
||||
// So the invariant is now the opposite: the README must NOT state a count. If
|
||||
// someone re-adds one, this fails and the reason is right here.
|
||||
//
|
||||
// Scope: this matches DIGIT forms ("119 tests", "10 checkpoints", "10/10 …").
|
||||
// It does not try to catch a count spelled out in words ("four files"), because
|
||||
// the README legitimately needs prose like "four files report ERR_MODULE_NOT_FOUND
|
||||
// — that is the missing-setup symptom" and a prose-matching rule would either
|
||||
// fire on that or grow into a mini parser. The rule targets the shape a
|
||||
// maintainer actually writes when pasting a run result, which is digits.
|
||||
test('README states no test or checkpoint counts', () => {
|
||||
const readme = readFileSync(join(new URL('..', import.meta.url).pathname, 'README.md'), 'utf8')
|
||||
|
||||
const offenders = []
|
||||
const patterns = [
|
||||
[/\b\d+\s+tests\b/i, 'a test count'],
|
||||
[/\b\d+\s+checkpoints\b/i, 'a checkpoint count'],
|
||||
[/\b\d+\s*\/\s*\d+\s+checkpoints passed/i, 'a pasted pass-count'],
|
||||
]
|
||||
for (const [pattern, label] of patterns) {
|
||||
const hit = readme.match(pattern)
|
||||
if (hit !== null) offenders.push(`${label}: "${hit[0]}"`)
|
||||
}
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
`README must not state counts — they drift and nobody guards them. Remove, don't update:\n${offenders.join('\n')}`,
|
||||
)
|
||||
})
|
||||
232
test/phase-b-g.test.js
Normal file
232
test/phase-b-g.test.js
Normal file
@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Phase B · group G — guards.
|
||||
*
|
||||
* These exist so that adding vectors cannot quietly damage what phase A shipped,
|
||||
* and so that the vector *transport* cannot leak into the layers that must stay
|
||||
* provider-agnostic.
|
||||
*
|
||||
* G1 phase A's whole surface still passes (unit + checkpoints) — the golden
|
||||
* fixture and the checkpoint script are the enforcement, this file only
|
||||
* pins the invariant that they are wired up.
|
||||
* G2 no embedder ⇒ no outbound HTTP, no extra module loaded.
|
||||
* G3 `stablemem_commit` still writes with a dead/absent embedder.
|
||||
* G4 media stays out of scope (one modality, `text`).
|
||||
* G5 provider-specific identifiers may exist in exactly one file.
|
||||
*
|
||||
* @module dsh-memory/test/phase-b-g
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import { ToolRuntime } from '@deepseek-ai/dsh-tools'
|
||||
import { apply as applyMemory } from '../lib/index.js'
|
||||
import { BlockStore, MODALITY_TEXT } from '../lib/store.js'
|
||||
|
||||
const LIB = new URL('../lib/', import.meta.url).pathname
|
||||
|
||||
/** Every .js file under lib/, recursively. */
|
||||
function libSources() {
|
||||
const out = []
|
||||
const walk = (dir) => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name)
|
||||
if (entry.isDirectory()) walk(full)
|
||||
else if (entry.name.endsWith('.js')) out.push(full)
|
||||
}
|
||||
}
|
||||
walk(LIB)
|
||||
return out
|
||||
}
|
||||
|
||||
/** A real cordis app carrying the services the plugin injects. */
|
||||
function makeApp() {
|
||||
const ctx = new Context()
|
||||
new SystemPrompt(ctx, {})
|
||||
new ToolRuntime(ctx, {})
|
||||
return ctx
|
||||
}
|
||||
|
||||
// ── G2: no embedder ⇒ no outbound HTTP, no extra deps ─────────────────────
|
||||
/**
|
||||
* Install a `fetch` tripwire for the duration of `body`, then restore it.
|
||||
* @param {(count: () => number) => Promise<void>} body
|
||||
*/
|
||||
async function withFetchTripwire(body) {
|
||||
const original = globalThis.fetch
|
||||
let calls = 0
|
||||
globalThis.fetch = (...args) => {
|
||||
calls += 1
|
||||
return Promise.reject(new Error(`unexpected fetch: ${args[0]}`))
|
||||
}
|
||||
try {
|
||||
await body(() => calls)
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
}
|
||||
|
||||
test('G2: with no embedder, recall performs no network I/O', async () => {
|
||||
// Any vector transport that tries to work without an embedder goes through here.
|
||||
await withFetchTripwire(async (calls) => {
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
store.putBlock({ text: '值班室分机号 4324' })
|
||||
const { recallBlocksHybrid } = await import('../lib/recall.js')
|
||||
const hits = await recallBlocksHybrid(store, null, '4324', { topK: 3 })
|
||||
assert.ok(hits.length >= 1, 'symbol path still recalls')
|
||||
assert.equal(calls(), 0, `no embedder must mean zero network calls; saw ${calls()}`)
|
||||
store.close()
|
||||
})
|
||||
})
|
||||
|
||||
// G2† — every early-degradation path must stay network-free.
|
||||
//
|
||||
// Rationale: G5 shipped a guard whose allowlist was silently always-false, and it
|
||||
// stayed green because nothing ever exercised it. The same shape applies here —
|
||||
// `recallBlocksHybrid` has FOUR early exits, and a hand-run check of one of them
|
||||
// proves nothing about the other three. Enumerated as data so a fifth branch
|
||||
// added later fails this test until it is covered.
|
||||
const DEGRADATION_PATHS = [
|
||||
{
|
||||
name: 'embedder is null',
|
||||
embedder: () => null,
|
||||
expect: 'no_embedder',
|
||||
},
|
||||
{
|
||||
name: 'embedder is undefined',
|
||||
embedder: () => undefined,
|
||||
expect: 'no_embedder',
|
||||
},
|
||||
{
|
||||
name: 'embedder has no embedText',
|
||||
embedder: () => ({ info: () => ({ fingerprint: 'fp', dimension: 3 }) }),
|
||||
expect: 'no_embedder',
|
||||
},
|
||||
{
|
||||
// ★ The branch this test was added for: non-null embedder, but it cannot
|
||||
// name its own vector space. It must degrade WITHOUT calling embedText —
|
||||
// and therefore without any transport being touched.
|
||||
name: 'embedder with no info()',
|
||||
embedder: () => ({ async embedText() { throw new Error('must not be called') } }),
|
||||
expect: 'no_embedder',
|
||||
},
|
||||
{
|
||||
name: 'embedder whose info() has an empty fingerprint',
|
||||
embedder: () => ({
|
||||
async embedText() { throw new Error('must not be called') },
|
||||
info: () => ({ fingerprint: '', dimension: 3 }),
|
||||
}),
|
||||
expect: 'no_embedder',
|
||||
},
|
||||
{
|
||||
name: 'embedder whose info() has a non-string fingerprint',
|
||||
embedder: () => ({
|
||||
async embedText() { throw new Error('must not be called') },
|
||||
info: () => ({ fingerprint: 42, dimension: 3 }),
|
||||
}),
|
||||
expect: 'no_embedder',
|
||||
},
|
||||
]
|
||||
|
||||
for (const path of DEGRADATION_PATHS) {
|
||||
test(`G2†: "${path.name}" degrades to ${path.expect} with zero network calls`, async () => {
|
||||
await withFetchTripwire(async (calls) => {
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
try {
|
||||
store.putBlock({ text: '值班室分机号 4324' })
|
||||
const { recallBlocksHybrid } = await import('../lib/recall.js')
|
||||
const hits = await recallBlocksHybrid(store, path.embedder(), '4324', { topK: 3 })
|
||||
assert.equal(hits.degraded, path.expect, `expected degraded=${path.expect}`)
|
||||
assert.ok(hits.length >= 1, 'symbol path must still recall on every degradation path')
|
||||
assert.equal(calls(), 0, `"${path.name}" must not touch the network; saw ${calls()}`)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test('G2†: the fingerprint check runs BEFORE embedText is called', async () => {
|
||||
// Ordering, pinned directly: a failing-fingerprint embedder must never be
|
||||
// asked for a vector. If a refactor moved the call earlier, this fails even
|
||||
// though the degraded value would still be 'no_embedder'.
|
||||
let embedCalls = 0
|
||||
const embedder = {
|
||||
async embedText() {
|
||||
embedCalls += 1
|
||||
return [0, 0, 0]
|
||||
},
|
||||
info: () => ({ fingerprint: '', dimension: 3 }),
|
||||
}
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
try {
|
||||
store.putBlock({ text: '值班室分机号 4324' })
|
||||
const { recallBlocksHybrid } = await import('../lib/recall.js')
|
||||
const hits = await recallBlocksHybrid(store, embedder, '4324', { topK: 3 })
|
||||
assert.equal(hits.degraded, 'no_embedder')
|
||||
assert.equal(embedCalls, 0, 'embedText must not be called when the space is unnamed')
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
// Scans SOURCE TEXT, not the loaded module. If it imported the module it would
|
||||
// crash on a missing package and report "load failed" — which is a different
|
||||
// finding than "a disallowed dependency was added", and the distinction matters
|
||||
// when triaging.
|
||||
// ── G3: commit must work with a dead embedder ────────────────────────────
|
||||
test('G3: stablemem_commit writes even when the embedder throws', async () => {
|
||||
const ctx = makeApp()
|
||||
const dir = await import('node:fs').then((fs) => fs.mkdtempSync(join('/tmp', 'g3-')))
|
||||
try {
|
||||
const mem = applyMemory(ctx, { path: join(dir, 'm.db') })
|
||||
mem.embedder = {
|
||||
async embedText() {
|
||||
throw new Error('vector service down')
|
||||
},
|
||||
info: () => ({ fingerprint: 'fp', dimension: 4 }),
|
||||
}
|
||||
const result = await ctx.tools.execute({
|
||||
callId: 'g3',
|
||||
name: 'stablemem_commit',
|
||||
arguments: { text: '向量服务挂了也要能写入:端口 2379', source: 'g3' },
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
assert.equal(result.isError, false, `commit must not fail: ${JSON.stringify(result)}`)
|
||||
assert.equal(mem.store.countBlocks(), 1, 'the block must be stored')
|
||||
// Recall still works through the symbol path.
|
||||
const hits = await mem.recall('2379')
|
||||
assert.ok(hits.length >= 1, 'symbol recall must still find it')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
const fs = await import('node:fs')
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// ── G4: media stays out ──────────────────────────────────────────────────
|
||||
test('G4: only the text modality is produced', () => {
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
try {
|
||||
store.putBlock({ text: 'a note' })
|
||||
const rows = store.allBlocks(10)
|
||||
assert.equal(rows.length, 1)
|
||||
for (const row of rows) {
|
||||
assert.equal(row.modality, MODALITY_TEXT, 'no non-text modality may be written')
|
||||
}
|
||||
// No media payload columns exist at all in this slice, so there is nowhere
|
||||
// for bytes to be stored even accidentally.
|
||||
const columns = store.columns('memory_blocks')
|
||||
for (const forbidden of ['data', 'mime', 'width', 'height']) {
|
||||
assert.ok(
|
||||
!columns.includes(forbidden),
|
||||
`memory_blocks must have no media column; found "${forbidden}" in ${columns.join(',')}`,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
4079
test/phase-b-h.test.js
Normal file
4079
test/phase-b-h.test.js
Normal file
File diff suppressed because it is too large
Load Diff
817
test/phase-b-p1-vector-wiring.test.js
Normal file
817
test/phase-b-p1-vector-wiring.test.js
Normal file
@ -0,0 +1,817 @@
|
||||
/**
|
||||
* P1 · Vector recall wiring — the seam that was computed and then thrown away.
|
||||
*
|
||||
* ## The defect this file exists for
|
||||
*
|
||||
* `lib/recall.js:681` ended with
|
||||
*
|
||||
* return withDiagnosis(recallBlocks(store, query, scoped), 'vector_ready_unused')
|
||||
*
|
||||
* `queryVector` had been computed, dimension-checked, and then deliberately
|
||||
* dropped: `scoped` carried only `topK` / `signal` / `fingerprint` / `dimension`.
|
||||
* So every V1–V7 scoring branch in `recallBlocks` was live code reachable ONLY
|
||||
* from unit tests that hand-built a `vectorScores` Map. In production the recall
|
||||
* path was pure symbol/FTS — the whole vector half of phase B was unreachable.
|
||||
*
|
||||
* Grep evidence at c1f7e06: `vectorScores:` appears 10 times in
|
||||
* `test/phase-b-a.test.js` and **zero** times anywhere under `lib/`.
|
||||
*
|
||||
* ## Why this criterion is end-to-end and not a function test
|
||||
*
|
||||
* A test that calls `recallBlocks(store, q, { vectorScores, minScore })` proves
|
||||
* the scoring seam works — which was never in doubt. It cannot see the bug,
|
||||
* because the bug is that nobody ever passes those two options. The only
|
||||
* observation that distinguishes the broken build from the fixed one is on the
|
||||
* far side of the whole chain:
|
||||
*
|
||||
* real sqlite file → real `apply()`/`commit()` write path → real
|
||||
* `recallBlocksHybrid` → real `readOnly` query embedding → `agent/pre-step`
|
||||
* injection → `renderRecall` → the system prompt → the model's answer
|
||||
*
|
||||
* So: a real `dsh --profile headless` process, a real on-disk database, an HTTP
|
||||
* embedding service the plugin reaches through its own `lib/embed/http.js`
|
||||
* adapter, and a stub LLM whose answer is a pure function of the system prompt it
|
||||
* received. The assertion is on the **final answer text**, which is as far from
|
||||
* the library boundary as the harness allows.
|
||||
*
|
||||
* ## Two preconditions, asserted before the answer is
|
||||
*
|
||||
* "The model answered QP47-812" alone would be satisfied by a lucky symbol hit.
|
||||
* The claim P1 needs is *"the vector route is what carried it"*, so the same turn
|
||||
* also proves:
|
||||
*
|
||||
* P1a symbol path only → zero hits, and specifically zero hits on block A
|
||||
* (the diagnostic `degraded` mode is printed as evidence);
|
||||
* P1b the injected text is attributable to the vector route (a `text`-tagged
|
||||
* hit carrying a vector score, not an `exact` pin).
|
||||
*
|
||||
* Without P1a/P1b a passing test would only show that *something* answered.
|
||||
*
|
||||
* ★ What this does NOT establish.
|
||||
* A one-hot keyword-family fixture proves PLUMBING, not retrieval quality.
|
||||
* It cannot speak to how a real embedder ranks real Chinese sentences — that
|
||||
* boundary was measured separately and is a KNOWN UNRESOLVED problem outside
|
||||
* P1's scope: on the real chinese-clip space a true hit scored 0.8345 while an
|
||||
* unrelated block scored 0.8598, so absolute scores are not calibrated and no
|
||||
* threshold may be read off them. A green P1 means "the wiring carries the
|
||||
* vector", never "semantic recall works".
|
||||
*
|
||||
* ## The corpus, and why these exact strings
|
||||
*
|
||||
* The fake embedder is a deterministic keyword-family projection into 8 unit
|
||||
* axes: family 0 is the target's vocabulary, families 1..7 are one axis per
|
||||
* distractor sentence. A text matching no family maps to the zero vector, so it
|
||||
* can never be a nearest neighbour by accident. This makes the "does the vector
|
||||
* route reach block A" question decidable with no model, no network, and no
|
||||
* tolerance — and it is what the corpus was **measured** against before any
|
||||
* assertion was written (see the pre-check test below, which is the executable
|
||||
* form of that measurement).
|
||||
*
|
||||
* The access code is `QP47-812`, not a bare number, so the stub LLM's answer
|
||||
* extractor (`[A-Z]{2}[0-9]{2}-[0-9]{3}`) can find it and nothing else in the
|
||||
* prompt can collide with it.
|
||||
*
|
||||
* ## Missing-harness conditions
|
||||
*
|
||||
* The harness needs `dsh` resolvable from PATH. When it is not, there is no
|
||||
* headless process to drive, and the criterion FAILS rather than skipping.
|
||||
*
|
||||
* ★ Why `assert.fail` and not `t.skip`: a skip is green. `npm test` would report
|
||||
* "1 skipped, N passed" and the only end-to-end evidence for P1 would never run —
|
||||
* exactly the "criterion is in the repo but never speaks" shape this whole slice
|
||||
* exists to close. It is also worse than a plain hole, because a green suite reads
|
||||
* as proof. `loop-system-e2e.test.js` still skips in the same situation; that file
|
||||
* was not the subject of P1 and is not changed here.
|
||||
*
|
||||
* @module dsh-memory/test/phase-b-p1-vector-wiring
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import test from 'node:test'
|
||||
import { BlockStore } from '../lib/store.js'
|
||||
import { recallBlocks, recallBlocksHybrid } from '../lib/recall.js'
|
||||
|
||||
// ── the corpus ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// One target ("A") and seven distractors. Every distractor owns exactly ONE
|
||||
// embedding axis, and the target owns the other one, so a query can only reach A
|
||||
// by landing on family 0.
|
||||
|
||||
/** Keyword families. Index 0 is the target's; 1..7 are one per distractor. */
|
||||
const FAMILIES = [
|
||||
['门禁码', 'QP47-812', '四位数', '暗号', '铁皮门', '货场', '进入凭证', '通行'],
|
||||
['午饭', '食堂', '十二点半'],
|
||||
['叉车', '额定载重', '两吨'],
|
||||
['打印机', '缺纸', '红灯'],
|
||||
['地铁', '园区北门'],
|
||||
['例会', '会议室'],
|
||||
['温度', '保护装置'],
|
||||
['包裹', '收发室'],
|
||||
]
|
||||
const DIMENSION = FAMILIES.length
|
||||
|
||||
/** The one block the model must be able to answer with. */
|
||||
const TARGET = '第七号仓库的备用门禁码是 QP47-812。'
|
||||
/** The code that must appear in the final answer, and nowhere else by accident. */
|
||||
const ANSWER = 'QP47-812'
|
||||
|
||||
const DISTRACTORS = [
|
||||
'午饭时间改到十二点半,食堂在东楼二层。',
|
||||
'叉车的额定载重是两吨,超了会翻。',
|
||||
'打印机缺纸时会闪红灯,备件在储物间第三格。',
|
||||
'地铁二号线直达园区北门,出站走五分钟。',
|
||||
'每月的第二个周五开例会,会议室在行政楼。',
|
||||
'温度超过八十五度,保护装置会切断电源。',
|
||||
'包裹统一送去一楼收发室的货架,凭手机尾号取。',
|
||||
]
|
||||
|
||||
/**
|
||||
* The question the user asks.
|
||||
*
|
||||
* Measured against the corpus before being pinned here: it extracts no symbol
|
||||
* (`querySymbols` → []), shares no CJK 2-gram with TARGET or any distractor, and
|
||||
* FTS finds nothing for it. It reaches family 0 only through `四位数`.
|
||||
*/
|
||||
const QUERY = '上次说的那个四位数是什么来着?'
|
||||
|
||||
/** The query's family axis. Asserted so a corpus edit cannot silently break it. */
|
||||
const QUERY_FAMILY = 0
|
||||
|
||||
/**
|
||||
* Deterministic keyword-family embedding.
|
||||
*
|
||||
* Returns a one-hot unit vector on the first matching family, or the zero vector
|
||||
* when nothing matches. Zero is deliberate: an unmapped text must not be a
|
||||
* neighbour of anything, so a false "vector hit" cannot be an artefact of a
|
||||
* default direction.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {number[]}
|
||||
*/
|
||||
function embedByFamily(text) {
|
||||
const haystack = String(text ?? '')
|
||||
const vector = new Array(DIMENSION).fill(0)
|
||||
for (let index = 0; index < FAMILIES.length; index += 1) {
|
||||
if (FAMILIES[index].some((keyword) => haystack.includes(keyword))) {
|
||||
vector[index] = 1
|
||||
return vector
|
||||
}
|
||||
}
|
||||
return vector
|
||||
}
|
||||
|
||||
/** Cosine similarity, written out so the fixture cannot depend on lib scoring. */
|
||||
function cosine(a, b) {
|
||||
let dot = 0
|
||||
let normA = 0
|
||||
let normB = 0
|
||||
for (let index = 0; index < a.length; index += 1) {
|
||||
dot += a[index] * b[index]
|
||||
normA += a[index] * a[index]
|
||||
normB += b[index] * b[index]
|
||||
}
|
||||
if (normA === 0 || normB === 0) return 0
|
||||
return dot / (Math.sqrt(normA) * Math.sqrt(normB))
|
||||
}
|
||||
|
||||
/** CJK 2-grams of a string, used to prove "no lexical overlap" mechanically. */
|
||||
function bigrams(text) {
|
||||
const chars = [...String(text ?? '')].filter((char) => /[\u3400-\u4dbf\u4e00-\u9fff]/.test(char))
|
||||
const out = new Set()
|
||||
for (let index = 0; index + 1 < chars.length; index += 1) out.add(chars[index] + chars[index + 1])
|
||||
return out
|
||||
}
|
||||
|
||||
// ── the fake embedding service ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* An HTTP embedding endpoint speaking the plugin's own wire protocol.
|
||||
*
|
||||
* This is deliberately a real socket, not an injected `config.embedder`: the
|
||||
* point of P1 is that the *production* path works, and the production path goes
|
||||
* through `createHttpEmbedder` in `lib/embed/http.js`. Injecting an embedder
|
||||
* object would leave that adapter — and the JSON shape it accepts — untested.
|
||||
*
|
||||
* @returns {Promise<{url: string, close: () => Promise<void>}>}
|
||||
*/
|
||||
async function startEmbedServer() {
|
||||
const server = createServer((request, response) => {
|
||||
const chunks = []
|
||||
request.on('data', (chunk) => chunks.push(chunk))
|
||||
request.on('end', () => {
|
||||
let text = ''
|
||||
try {
|
||||
text = JSON.parse(Buffer.concat(chunks).toString('utf8')).text ?? ''
|
||||
} catch {
|
||||
response.writeHead(400).end('bad json')
|
||||
return
|
||||
}
|
||||
const body = JSON.stringify({ embedding: embedByFamily(text) })
|
||||
response.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) })
|
||||
response.end(body)
|
||||
})
|
||||
})
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const { port } = server.address()
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}/embed`,
|
||||
close: () => new Promise((resolve) => server.close(resolve)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── the stub LLM ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* An OpenAI-compatible endpoint whose answer is a pure function of the SYSTEM
|
||||
* prompt it receives.
|
||||
*
|
||||
* ★ Why a stub rather than a real provider: the assertion is "did block A reach
|
||||
* the system prompt". A real model answers probabilistically, so a red run could
|
||||
* mean "the model was unlucky" and a green run could mean "the model guessed the
|
||||
* code". This stub makes the measurement exact: if the code is in the system
|
||||
* prompt the answer IS the code; if it is not, the answer is 未知.
|
||||
*
|
||||
* It is still a real HTTP provider from dsh's point of view — the plugin, the
|
||||
* harness, the request builder, and `agent/pre-step` are all the production ones.
|
||||
*
|
||||
* @returns {Promise<{url: string, close: () => Promise<void>}>}
|
||||
*/
|
||||
async function startStubLlm() {
|
||||
const answerFor = (messages) => {
|
||||
const system = messages
|
||||
.filter((message) => message?.role === 'system')
|
||||
.map((message) => (typeof message.content === 'string'
|
||||
? message.content
|
||||
: (message.content ?? []).map((block) => (typeof block?.text === 'string' ? block.text : '')).join('')))
|
||||
.join('\n')
|
||||
const found = system.match(/[A-Z]{2}\d{2}-\d{3}/)
|
||||
return found === null ? '未知' : found[0]
|
||||
}
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
if (request.method === 'GET') {
|
||||
const body = JSON.stringify({ object: 'list', data: [{ id: 'stub-1', object: 'model' }] })
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' }).end(body)
|
||||
return
|
||||
}
|
||||
const chunks = []
|
||||
request.on('data', (chunk) => chunks.push(chunk))
|
||||
request.on('end', () => {
|
||||
let payload = {}
|
||||
try {
|
||||
payload = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
} catch {
|
||||
response.writeHead(400).end('bad json')
|
||||
return
|
||||
}
|
||||
const answer = answerFor(payload.messages ?? [])
|
||||
const base = { id: 'chatcmpl-stub', created: 0, model: payload.model ?? 'stub-1' }
|
||||
|
||||
if (payload.stream === true) {
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'close',
|
||||
})
|
||||
const send = (chunk) => response.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||
send({ ...base, object: 'chat.completion.chunk', choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] })
|
||||
send({ ...base, object: 'chat.completion.chunk', choices: [{ index: 0, delta: { content: answer }, finish_reason: null }] })
|
||||
send({ ...base, object: 'chat.completion.chunk', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })
|
||||
response.end('data: [DONE]\n\n')
|
||||
return
|
||||
}
|
||||
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
|
||||
...base,
|
||||
object: 'chat.completion',
|
||||
choices: [{ index: 0, message: { role: 'assistant', content: answer }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
}))
|
||||
})
|
||||
})
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const { port } = server.address()
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}/v1`,
|
||||
close: () => new Promise((resolve) => server.close(resolve)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── the headless harness ──────────────────────────────────────────────────────
|
||||
|
||||
/** True when a `dsh` binary is on PATH and resolves. */
|
||||
function dshAvailable() {
|
||||
try {
|
||||
execFileSync('dsh', ['--version'], { stdio: 'ignore' })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A private DSH_HOME with the stock headless profile's bundles.
|
||||
*
|
||||
* Written to a temp dir instead of reusing `$DSH_HOME` so the run cannot inherit
|
||||
* whatever the developer has configured (a different embedder, a different memory
|
||||
* database, a debug toggle). The only inputs are the ones this file supplies.
|
||||
*
|
||||
* @param {string} home
|
||||
* @param {string} packageRoot
|
||||
*/
|
||||
function writeProfile(home, packageRoot) {
|
||||
const profileDir = join(home, 'profiles', 'headless')
|
||||
execFileSync('mkdir', ['-p', join(profileDir, 'node_modules')])
|
||||
execFileSync('sh', ['-c', `ln -sfn ${JSON.stringify(packageRoot)} ${JSON.stringify(join(profileDir, 'node_modules', 'dsh-memory'))}`])
|
||||
// An empty tree: the bundles in package.json supply the base rows, and the
|
||||
// overlay supplies `memory`. Nothing from the developer's own profile leaks in.
|
||||
execFileSync('sh', ['-c', `printf '[]\\n' > ${JSON.stringify(join(profileDir, 'cordis.yml'))}`])
|
||||
execFileSync('sh', ['-c', `cat > ${JSON.stringify(join(profileDir, 'package.json'))} <<'P1PKG'
|
||||
${JSON.stringify({
|
||||
name: 'dsh-profile-headless',
|
||||
private: true,
|
||||
dependencies: { 'dsh-memory': `link:${packageRoot}` },
|
||||
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'] } },
|
||||
}, null, 2)}
|
||||
P1PKG`])
|
||||
return profileDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one headless task and return the final answer text plus stderr.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.home private DSH_HOME
|
||||
* @param {string} options.profileDir
|
||||
* @param {string} options.dbPath
|
||||
* @param {string} options.embedUrl
|
||||
* @param {string} options.llmUrl
|
||||
* @param {string} options.query
|
||||
* @param {string} options.cwd
|
||||
* @returns {Promise<{answer: string, stderr: string, sessionId: string|null}>}
|
||||
*/
|
||||
function runHeadless({ home, profileDir, dbPath, embedUrl, llmUrl, query, cwd }) {
|
||||
const overlay = join(profileDir, 'p1-overlay.yml')
|
||||
// ★ `insert:` with an explicit `name:` and NOT a bare `- id: memory` merge.
|
||||
// The private DSH_HOME written by {@link writeProfile} has no `memory` row of
|
||||
// its own, so a merge-style patch would silently compose an entry with no
|
||||
// plugin behind it: the config would appear in `--dump-config`, the harness
|
||||
// would boot, and the recall hook would simply never be registered. That is a
|
||||
// no-op that looks configured — the same shape as the bug this file pins.
|
||||
// The two other rows are base entries and are legitimately merged by id.
|
||||
const yaml = [
|
||||
'- insert:',
|
||||
' - id: memory',
|
||||
' name: dsh-memory',
|
||||
' config:',
|
||||
` path: ${JSON.stringify(dbPath)}`,
|
||||
' topK: 3',
|
||||
' debugLog: true',
|
||||
' inject: true',
|
||||
' embed:',
|
||||
` endpoint: ${JSON.stringify(embedUrl)}`,
|
||||
` dimension: ${DIMENSION}`,
|
||||
' model: p1-keyword-family',
|
||||
'- id: agent-default-model',
|
||||
' config:',
|
||||
' provider: stub',
|
||||
' model: stub-1',
|
||||
'- id: llm-pi-ai',
|
||||
' config:',
|
||||
' providers:',
|
||||
' stub:',
|
||||
' displayName: stub',
|
||||
' api: openai-completions',
|
||||
` baseURL: ${JSON.stringify(llmUrl)}`,
|
||||
' apiKeyEnv: P1_STUB_KEY',
|
||||
' models:',
|
||||
' - id: stub-1',
|
||||
' name: stub-1',
|
||||
' contextWindow: 65536',
|
||||
// ── P0: the replacement loop must be mounted ────────────────────────────
|
||||
//
|
||||
// Without these two rows the run exercises the STOCK agent loop, which leaves
|
||||
// the recall in user-message history. The recall would then never reach the
|
||||
// system prompt, and P1's "the answer came from a system section" claim would
|
||||
// be unprovable — worse, the assertion would pass for the wrong reason if the
|
||||
// stub LLM were to look at history too (it deliberately does not, which is
|
||||
// what makes the failure visible). This is byte-for-byte the shipped
|
||||
// `cordis.patch.yml` overlay, so the run composes the same tree the profile
|
||||
// does. It also pins the P0 result: 377e02c + c1f7e06 must survive P1.
|
||||
'- id: agent-loop',
|
||||
' disabled: true',
|
||||
'- insert:',
|
||||
' - id: hologram-agent-loop',
|
||||
' name: dsh-memory/loop',
|
||||
'',
|
||||
].join('\n')
|
||||
execFileSync('sh', ['-c', `cat > ${JSON.stringify(overlay)} <<'P1YAML'\n${yaml}P1YAML`])
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
'dsh',
|
||||
['--profile', 'headless', '--patch', overlay, '--json', query],
|
||||
{ cwd, env: { ...process.env, DSH_HOME: home, P1_STUB_KEY: 'p1-stub-key' } },
|
||||
)
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk })
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk })
|
||||
child.on('error', reject)
|
||||
child.on('close', (code) => {
|
||||
const events = stdout
|
||||
.split('\n')
|
||||
.filter((line) => line.trim() !== '')
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((event) => event !== null)
|
||||
const final = events.findLast((event) => event.type === 'final')
|
||||
const session = events.find((event) => event.type === 'session')
|
||||
if (final === undefined) {
|
||||
reject(new Error(`headless run produced no final event (exit ${code})\nstderr:\n${stderr}\nstdout:\n${stdout}`))
|
||||
return
|
||||
}
|
||||
resolve({ answer: final.text ?? '', stderr, sessionId: session?.sessionId ?? null })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress a session log and return its JSONL lines.
|
||||
*
|
||||
* `session.v4.jsonl.zstd` is what the harness actually persists, so it is what
|
||||
* an operator would read. Parsing it (rather than trusting the run's own stdout)
|
||||
* is the only way to see the injected prompt the model was really given.
|
||||
*
|
||||
* @param {string} home
|
||||
* @param {string} sessionId
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function readSessionLines(home, sessionId) {
|
||||
// The workspace key is a lossy slug of the launch cwd, so it cannot be
|
||||
// reconstructed reliably (a real directory called `a-b` and one called `a/b`
|
||||
// collide). Search for the session directory by its id instead — the id is
|
||||
// exact and unique.
|
||||
const sessionsRoot = join(home, 'sessions')
|
||||
const candidates = existsSync(sessionsRoot)
|
||||
? execFileSync('sh', ['-c', `find ${JSON.stringify(sessionsRoot)} -maxdepth 2 -type d -name ${JSON.stringify(sessionId)}`])
|
||||
.toString('utf8')
|
||||
.split('\n')
|
||||
.filter((line) => line.trim() !== '')
|
||||
: []
|
||||
assert.equal(
|
||||
candidates.length,
|
||||
1,
|
||||
`exactly one session directory must exist for ${sessionId} under ${sessionsRoot}; found ${JSON.stringify(candidates)}`,
|
||||
)
|
||||
const file = join(candidates[0], 'session.v4.jsonl.zstd')
|
||||
assert.ok(existsSync(file), `session log must exist at ${file}`)
|
||||
return execFileSync('zstd', ['-dc', file], { maxBuffer: 256 * 1024 * 1024 })
|
||||
.toString('utf8')
|
||||
.split('\n')
|
||||
.filter((line) => line.trim() !== '')
|
||||
}
|
||||
|
||||
/** Every system-prompt body in a session log, joined. */
|
||||
function systemPromptFrom(lines) {
|
||||
return lines
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((event) => event?.type === 'system/message')
|
||||
.map((event) => (event.data?.message?.content ?? [])
|
||||
.map((block) => (typeof block?.text === 'string' ? block.text : ''))
|
||||
.join(''))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
// ── module setup shared by every case ─────────────────────────────────────────
|
||||
|
||||
const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
|
||||
/** A store seeded through `putBlock` with family vectors, for the library-level checks. */
|
||||
function seededStore() {
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
const fingerprint = 'p1-keyword-family'
|
||||
const targetId = store.putBlock({ text: TARGET, source: 'p1', vector: embedByFamily(TARGET), fingerprint }).id
|
||||
const distractorIds = DISTRACTORS.map((text) => store.putBlock({
|
||||
text, source: 'p1', vector: embedByFamily(text), fingerprint,
|
||||
}).id)
|
||||
return { store, fingerprint, targetId, distractorIds }
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// P1-0 · corpus pre-check — the executable form of "measured before asserted"
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* The pre-check that must pass BEFORE the criterion is allowed to assert
|
||||
* anything. It fails on a corpus problem, never on the wiring bug, and it is
|
||||
* kept in the suite so a later corpus edit cannot quietly make the e2e
|
||||
* criterion unfalsifiable.
|
||||
*
|
||||
* Two conditions, both mandated:
|
||||
* 1. the symbol path returns NO hit for the target (so a green answer can
|
||||
* only be the vector route);
|
||||
* 2. with a hand-built `vectorScores` Map plus `minScore: 0`, the target IS
|
||||
* top-1 (so the corpus and vectors can actually reach it at all).
|
||||
*/
|
||||
test('P1-0: the corpus is admissible — symbol path blind, vector path reaches the target', () => {
|
||||
const { store, targetId } = seededStore()
|
||||
try {
|
||||
// ── the query really does extract no symbol and share no 2-gram ──────────
|
||||
const queryVector = embedByFamily(QUERY)
|
||||
assert.equal(
|
||||
queryVector.indexOf(1),
|
||||
QUERY_FAMILY,
|
||||
`the query must land on family ${QUERY_FAMILY}; got ${JSON.stringify(queryVector)}`,
|
||||
)
|
||||
const queryGrams = bigrams(QUERY)
|
||||
for (const text of [TARGET, ...DISTRACTORS]) {
|
||||
const shared = [...bigrams(text)].filter((gram) => queryGrams.has(gram))
|
||||
assert.deepEqual(
|
||||
shared,
|
||||
[],
|
||||
`precondition: the query must share no CJK 2-gram with "${text}"; shared ${JSON.stringify(shared)}`,
|
||||
)
|
||||
}
|
||||
|
||||
// ── condition 1: the symbol path is blind to the target ─────────────────
|
||||
const symbolOnly = recallBlocks(store, QUERY, { topK: 5 })
|
||||
assert.equal(
|
||||
symbolOnly.filter((hit) => hit.id === targetId).length,
|
||||
0,
|
||||
`precondition 1: the symbol path must NOT find the target; it returned ${JSON.stringify(
|
||||
symbolOnly.map((hit) => [hit.text, hit.score]),
|
||||
)}`,
|
||||
)
|
||||
|
||||
// ── condition 2: a manually built vector route DOES reach the target ────
|
||||
const scores = new Map(store.allBlocks(-1).map((block) => [
|
||||
block.id,
|
||||
cosine(queryVector, JSON.parse(block.vector)),
|
||||
]))
|
||||
const fused = recallBlocks(store, QUERY, { topK: 3, vectorScores: scores, minScore: 0 })
|
||||
assert.ok(fused.length > 0, 'precondition 2: the fused route must return something')
|
||||
assert.equal(
|
||||
fused[0].id,
|
||||
targetId,
|
||||
`precondition 2: the target must be top-1 once vectors participate; got ${
|
||||
JSON.stringify(fused.map((hit) => [hit.text, hit.score]))
|
||||
}`,
|
||||
)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// P1 · the end-to-end criterion
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('P1: a semantic-only question is answered from block memory via the vector route', async (t) => {
|
||||
if (!dshAvailable()) {
|
||||
assert.fail(
|
||||
'the P1 end-to-end criterion cannot be established: no `dsh` binary on PATH. ' +
|
||||
'This is a SKIP-shaped hole, not a pass — a silent skip would keep npm test green ' +
|
||||
'while the only end-to-end evidence for P1 never runs.',
|
||||
)
|
||||
}
|
||||
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'p1-e2e-'))
|
||||
const home = join(workspace, 'home')
|
||||
const cwd = join(workspace, 'work')
|
||||
execFileSync('mkdir', ['-p', cwd])
|
||||
const profileDir = writeProfile(home, PACKAGE_ROOT)
|
||||
const dbPath = join(workspace, 'memory.db')
|
||||
|
||||
const embedServer = await startEmbedServer()
|
||||
const llmServer = await startStubLlm()
|
||||
try {
|
||||
// ── seed through the real plugin write path ──────────────────────────────
|
||||
//
|
||||
// `apply()` builds the plugin exactly as the profile does, `commit()` runs
|
||||
// the production embed → `putBlock(vector, fingerprint)` path. Nothing here
|
||||
// writes a vector by hand: a block that reached the database without the
|
||||
// real write path could not prove the read path is wired.
|
||||
const { Context } = await import('@deepseek-ai/cordis')
|
||||
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
|
||||
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
|
||||
const { apply: applyMemory } = await import('../lib/index.js')
|
||||
|
||||
const seedCtx = new Context()
|
||||
let space
|
||||
try {
|
||||
new SystemPrompt(seedCtx, {})
|
||||
new ToolRuntime(seedCtx)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
const mem = applyMemory(seedCtx, {
|
||||
path: dbPath,
|
||||
topK: 3,
|
||||
debugLog: false,
|
||||
inject: true,
|
||||
embed: { endpoint: embedServer.url, dimension: DIMENSION, model: 'p1-keyword-family' },
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
space = mem.space()
|
||||
assert.ok(space !== null && space.dimension === DIMENSION, `the plugin must expose the fake space; got ${JSON.stringify(space)}`)
|
||||
|
||||
for (const text of [TARGET, ...DISTRACTORS]) {
|
||||
const written = await mem.commit({ text, source: 'p1-seed' })
|
||||
assert.equal(
|
||||
written.embedded,
|
||||
true,
|
||||
`seed block must be stored WITH a vector (real embed path), not symbol-only: ${text}`,
|
||||
)
|
||||
}
|
||||
assert.equal(mem.store.countBlocks(), 1 + DISTRACTORS.length, 'every corpus block must be on disk')
|
||||
} finally {
|
||||
await seedCtx.fiber.dispose()
|
||||
}
|
||||
|
||||
// ── P1a · the symbol path is blind, proved on the real on-disk database ──
|
||||
const store = new BlockStore({ path: dbPath }).open()
|
||||
let symbolHits
|
||||
try {
|
||||
symbolHits = recallBlocks(store, QUERY, { topK: 5, fingerprint: space.fingerprint, dimension: space.dimension })
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
assert.equal(
|
||||
symbolHits.length,
|
||||
0,
|
||||
`P1a precondition: the symbol path must find NOTHING for this query; got ${JSON.stringify(
|
||||
symbolHits.map((hit) => [hit.text, hit.score]),
|
||||
)}`,
|
||||
)
|
||||
|
||||
// The current build's own diagnostic, captured before the run: on c1f7e06 this
|
||||
// is `vector_ready_unused` and the array is empty.
|
||||
const hybridStore = new BlockStore({ path: dbPath }).open()
|
||||
let hybridDiagnosis
|
||||
try {
|
||||
const probe = await import('../lib/recall.js')
|
||||
const hybrid = await probe.recallBlocksHybrid(
|
||||
hybridStore,
|
||||
{
|
||||
embedText: async (text) => embedByFamily(text),
|
||||
info: () => space,
|
||||
},
|
||||
QUERY,
|
||||
{ topK: 3 },
|
||||
)
|
||||
hybridDiagnosis = { mode: hybrid.degraded ?? 'ok', hits: hybrid.length }
|
||||
} finally {
|
||||
hybridStore.close()
|
||||
}
|
||||
|
||||
// ── the real headless run ────────────────────────────────────────────────
|
||||
const run = await runHeadless({
|
||||
home, profileDir, dbPath, embedUrl: embedServer.url, llmUrl: llmServer.url, query: QUERY, cwd,
|
||||
})
|
||||
|
||||
// ── the injected text, recovered from the persisted session log ──────────
|
||||
//
|
||||
// Everything below is derived from the harness's own on-disk record, not from
|
||||
// the run's stdout: the session log carries the exact system prompt the
|
||||
// adapter was handed, which is the only place the injection is observable.
|
||||
assert.ok(
|
||||
run.sessionId !== null,
|
||||
`the run must report its session id; stderr was:\n${run.stderr}`,
|
||||
)
|
||||
const lines = readSessionLines(home, run.sessionId)
|
||||
const systemPrompt = systemPromptFrom(lines)
|
||||
// The recall SECTION, not just its header line: `renderRecall` emits a header
|
||||
// and then one line per hit, so the entry lines (which carry the `text` tag and
|
||||
// the score) live BELOW the header. Taking the section rather than the first
|
||||
// matching line is what lets P1c assert on the hit's shape.
|
||||
const systemLines = systemPrompt.split('\n')
|
||||
const recallStart = systemLines.findIndex((line) => line.includes('Recalled from durable block memory'))
|
||||
const recallLine = recallStart === -1 ? undefined : systemLines.slice(recallStart).join('\n')
|
||||
|
||||
t.diagnostic(`P1 symbol-path baseline: hits=${symbolHits.length}`)
|
||||
t.diagnostic(`P1 recall diagnosis before the run: degraded=${hybridDiagnosis.mode} hits=${hybridDiagnosis.hits}`)
|
||||
t.diagnostic(`P1 recall line injected: ${recallLine ?? '(none)'}`)
|
||||
|
||||
// ── P1 · the answer ──────────────────────────────────────────────────────
|
||||
//
|
||||
// The primary assertion. On c1f7e06 the recall array is empty (`hits=0`), so
|
||||
// the stub LLM — which answers ONLY with a code it finds in the system prompt
|
||||
// — replies 未知. That failure IS the defect: the vector was computed and
|
||||
// thrown away.
|
||||
assert.equal(
|
||||
run.answer.trim(),
|
||||
ANSWER,
|
||||
`P1: the answer must come from block memory through the vector route.\n` +
|
||||
` query: ${QUERY}\n` +
|
||||
` expected: ${ANSWER}\n` +
|
||||
` actual: ${JSON.stringify(run.answer)}\n` +
|
||||
` symbol-path hits: ${symbolHits.length} (must be 0)\n` +
|
||||
` recall diagnosis: degraded=${hybridDiagnosis.mode} hits=${hybridDiagnosis.hits}\n` +
|
||||
` recall section injected: ${recallLine ?? '(none)'}`,
|
||||
)
|
||||
|
||||
// ── P1b · the recall reached the SYSTEM prompt, not user history (P0) ────
|
||||
//
|
||||
// P0 (377e02c + c1f7e06) must still hold after the wiring change: the recall
|
||||
// is a system *section*, so it may appear in no non-system message body.
|
||||
assert.ok(
|
||||
recallLine !== undefined,
|
||||
`P1b: the recall section must reach the system prompt (P0 must still hold). ` +
|
||||
`degraded=${hybridDiagnosis.mode} symbolHits=${symbolHits.length}\n` +
|
||||
`stderr:\n${run.stderr}`,
|
||||
)
|
||||
const leakingBodies = lines
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((event) => event?.type === 'user/message' || event?.type === 'assistant/message')
|
||||
.map((event) => JSON.stringify(event.data ?? {}))
|
||||
.filter((body) => body.includes(TARGET))
|
||||
const recallCarrierMessages = (() => {
|
||||
// Count carriers by role across every message the harness recorded.
|
||||
const carriers = []
|
||||
for (const line of lines) {
|
||||
let event
|
||||
try {
|
||||
event = JSON.parse(line)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const message = event?.data?.message ?? event?.data
|
||||
const text = (message?.content ?? [])
|
||||
.map((block) => (typeof block?.text === 'string' ? block.text : ''))
|
||||
.join('')
|
||||
if (typeof message?.role === 'string' && text.includes(TARGET)) carriers.push(message.role)
|
||||
}
|
||||
return carriers
|
||||
})()
|
||||
t.diagnostic(`P1b recall carriers by role: ${JSON.stringify(recallCarrierMessages)}`)
|
||||
t.diagnostic(`P1b non-system bodies containing the target: ${JSON.stringify(leakingBodies)}`)
|
||||
assert.deepEqual(
|
||||
leakingBodies,
|
||||
[],
|
||||
'P1b: the recalled note must NOT appear in any user/assistant message body (a move, not a copy)',
|
||||
)
|
||||
// Positive half of the same claim: exactly ONE message carries it, and that
|
||||
// message is the system prompt. Without this, a build that dropped the recall
|
||||
// from the prompt entirely could still satisfy the negative assertion above —
|
||||
// and P0 is precisely the property that must survive P1.
|
||||
assert.deepEqual(
|
||||
recallCarrierMessages,
|
||||
['system'],
|
||||
`P1b: the recalled note must be carried by exactly one message, the system prompt; got ${JSON.stringify(
|
||||
recallCarrierMessages,
|
||||
)}`,
|
||||
)
|
||||
|
||||
// ── P1c · the hit is a VECTOR hit, not an exact symbol pin ──────────────
|
||||
//
|
||||
// `renderRecall` tags a hit `(exact 1.0x)` when a numeric symbol pinned it and
|
||||
// `(text 0.xx)` otherwise. Our corpus guarantees no symbol match, so a `text`
|
||||
// tag with a sub-1.0 score is the observable signature of the fused vector
|
||||
// route (`vec*0.7 + sym*0.3`). If the wiring regressed to a symbol-only path
|
||||
// this tag would read `exact`, and if vectors stopped participating entirely
|
||||
// there would be no line at all (caught by P1 above).
|
||||
assert.match(
|
||||
recallLine,
|
||||
/\[\d+\] \(text 0\.\d{2}\) .*QP47-812/,
|
||||
`P1c: the injected entry must be a \`text\`-tagged hit (vector route) carrying the target, ` +
|
||||
`not an \`exact\` symbol pin. recall line was: ${recallLine}\n` +
|
||||
`diagnosis at probe time: degraded=${hybridDiagnosis.mode} hits=${hybridDiagnosis.hits}`,
|
||||
)
|
||||
} finally {
|
||||
await embedServer.close()
|
||||
await llmServer.close()
|
||||
// ★ `P1_KEEP=1` keeps the workspace so an operator (or a review) can read the
|
||||
// session log, the sqlite file and the composed overlay by hand. Off by
|
||||
// default: a test that leaves databases behind on every run turns /tmp into a
|
||||
// second, unaudited store of memory. The directory is printed either way, so a
|
||||
// preserved run is discoverable from the test output alone.
|
||||
if (process.env.P1_KEEP === '1') {
|
||||
t.diagnostic(`P1 workspace kept: ${workspace}`)
|
||||
console.log(`P1 workspace kept: ${workspace}`)
|
||||
} else {
|
||||
rmSync(workspace, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
153
test/phase-b-v6-sides.test.js
Normal file
153
test/phase-b-v6-sides.test.js
Normal file
@ -0,0 +1,153 @@
|
||||
/**
|
||||
* V6 · 写入侧与查询侧必须用不同的 `side`。
|
||||
*
|
||||
* ## 病
|
||||
*
|
||||
* HA 的 HTTP 嵌入协议带一个 `side` 字段(`query` / `document`,
|
||||
* `http_embedder.go:105` / `:109`;枚举 `PurposeQuery` / `PurposeDocument`)。
|
||||
* 检索类模型通常对两侧做不同的编码——存进来的文档向量与提问的查询向量落在
|
||||
* 同一个坐标系的**不同子空间**里。两侧都用 `query` 时,块向量和查询向量都
|
||||
* 是「正确宽度、正确模长的单位向量」,所以没有任何东西会报错:只是在真正
|
||||
* 需要语义匹配时对不上。
|
||||
*
|
||||
* 移植时 `side` 被做进了 adapter 的构造参数(默认 `query`),但**没有一个
|
||||
* 调用点传它**:写入路径与召回路径都走默认值。于是「文档按查询侧编码」这件
|
||||
* 事发生了,且无法从任何返回值上观察到。
|
||||
*
|
||||
* ## 判据形态
|
||||
*
|
||||
* 起一个真的 HTTP 服务,把每个请求体里的 `side` 记下来,然后用**生产装配**
|
||||
* (`apply()` + `config.embed`)跑一次写入和一次召回:
|
||||
*
|
||||
* commit → 请求体 side 必须是 'document'
|
||||
* recall → 请求体 side 必须是 'query'
|
||||
*
|
||||
* 断言落在**线上真正发出去的字段**,不是内部变量。变异(写入侧改回共用
|
||||
* 查询实例)会让 commit 的 side 变回 'query',这条立刻红。
|
||||
*
|
||||
* ★ 为什么不用注入式 embedder:注入的是单个对象,没有「两个编码器」可言,
|
||||
* 绕开它就把被测的装配逻辑整个跳过了。这条判据必须走 HTTP 装配。
|
||||
*
|
||||
* @module dsh-memory/test/phase-b-v6-sides
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { createServer } from 'node:http'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const DIM = 4
|
||||
const unit = (v) => { const n = Math.hypot(...v); return n === 0 ? v : v.map((x) => x / n) }
|
||||
const vecFor = (text) => unit([1, String(text).length % 7 === 0 ? 0.4 : 0.2, 0.1, 0])
|
||||
|
||||
/** 起一个记录 `side` 的嵌入服务。 */
|
||||
async function startRecordingEmbedder() {
|
||||
const seen = []
|
||||
const server = createServer((req, res) => {
|
||||
const chunks = []
|
||||
req.on('data', (c) => chunks.push(c))
|
||||
req.on('end', () => {
|
||||
let payload = {}
|
||||
try { payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { /* ignore */ }
|
||||
seen.push({ side: payload.side, modality: payload.modality, text: payload.text })
|
||||
const body = JSON.stringify({ embedding: vecFor(payload.text) })
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) })
|
||||
res.end(body)
|
||||
})
|
||||
})
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
return {
|
||||
url: `http://127.0.0.1:${server.address().port}/embed`,
|
||||
seen,
|
||||
close: () => new Promise((resolve) => server.close(resolve)),
|
||||
}
|
||||
}
|
||||
|
||||
test('V6: storing a block encodes it on the document side, asking encodes the query side', async () => {
|
||||
const { Context } = await import('@deepseek-ai/cordis')
|
||||
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
|
||||
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
|
||||
const { apply } = await import('../lib/index.js')
|
||||
|
||||
const embed = await startRecordingEmbedder()
|
||||
const dir = mkdtempSync(join(tmpdir(), 'v6-'))
|
||||
const ctx = new Context()
|
||||
new SystemPrompt(ctx, {})
|
||||
new ToolRuntime(ctx, {})
|
||||
try {
|
||||
// ★ 生产装配:给它 config.embed,让它自己建两个编码器。
|
||||
const mem = apply(ctx, {
|
||||
path: join(dir, 'm.db'),
|
||||
embed: { endpoint: embed.url, dimension: DIM, model: 'v6-probe' },
|
||||
})
|
||||
|
||||
// ── 写入侧 ────────────────────────────────────────────────────────────
|
||||
const written = await mem.commit({ text: '值班室的分机号是 4324' })
|
||||
assert.equal(written.embedded, true, 'precondition: the block must actually be embedded')
|
||||
|
||||
const afterWrite = embed.seen.at(-1)
|
||||
assert.equal(
|
||||
afterWrite?.side,
|
||||
'document',
|
||||
'V6: a block being STORED must be encoded on the document side. Both sides '
|
||||
+ 'defaulting to "query" is invisible at runtime — the vectors have the right '
|
||||
+ 'width and length either way — and it silently puts stored memories in a '
|
||||
+ `different subspace from the questions. got side=${JSON.stringify(afterWrite?.side)}`,
|
||||
)
|
||||
assert.equal(afterWrite.modality, 'text', 'and the modality field still travels')
|
||||
|
||||
// ── 召回侧 ────────────────────────────────────────────────────────────
|
||||
const before = embed.seen.length
|
||||
await mem.recall('分机号是多少')
|
||||
const afterRecall = embed.seen.slice(before)
|
||||
assert.equal(afterRecall.length, 1, `precondition: the recall must embed exactly once; got ${afterRecall.length}`)
|
||||
assert.equal(
|
||||
afterRecall[0].side,
|
||||
'query',
|
||||
'V6: a QUESTION must be encoded on the query side',
|
||||
)
|
||||
|
||||
// ── 两侧的指纹必须一致,否则块与中心会落在两个空间里 ────────────────
|
||||
assert.equal(
|
||||
mem.space().dimension,
|
||||
DIM,
|
||||
'V6: the two encoders must still describe ONE space (the fingerprint feeds '
|
||||
+ 'both memory_blocks and graph_centroid)',
|
||||
)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
await embed.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('V6: an injected single embedder keeps working and is used for both sides', async () => {
|
||||
const { Context } = await import('@deepseek-ai/cordis')
|
||||
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
|
||||
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
|
||||
const { apply } = await import('../lib/index.js')
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'v6-inj-'))
|
||||
let calls = 0
|
||||
const injected = {
|
||||
async embedText() { calls += 1; return vecFor('x') },
|
||||
info: () => ({ fingerprint: 'v6:injected:test', dimension: DIM }),
|
||||
}
|
||||
const ctx = new Context()
|
||||
new SystemPrompt(ctx, {})
|
||||
new ToolRuntime(ctx, {})
|
||||
try {
|
||||
// 注入式 embedder 没有「两个编码器」可言:它必须照旧被两侧共用,
|
||||
// 否则所有既有测试与单侧适配器都会被打断。
|
||||
const mem = apply(ctx, { path: join(dir, 'm.db'), embedder: injected })
|
||||
const written = await mem.commit({ text: '值班室的分机号是 4324' })
|
||||
assert.equal(written.embedded, true)
|
||||
await mem.recall('分机号是多少')
|
||||
assert.ok(calls >= 2, `the single injected embedder must serve both paths; got ${calls} calls`)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
549
test/plugin.test.js
Normal file
549
test/plugin.test.js
Normal file
@ -0,0 +1,549 @@
|
||||
/**
|
||||
* Plugin-wiring tests against **real** dsh services.
|
||||
*
|
||||
* These are the assertions that would silently rot if dsh changed a hook, so they
|
||||
* run the actual `SystemPrompt` and `ToolRuntime` rather than stubs. The
|
||||
* checkpoint script covers the same ground with human-readable output; this file
|
||||
* is what CI should run.
|
||||
*
|
||||
* Run: node --test test/
|
||||
*
|
||||
* @module dsh-memory/test/plugin
|
||||
*/
|
||||
|
||||
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 { Context } from '@deepseek-ai/cordis'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import { ToolRuntime } from '@deepseek-ai/dsh-tools'
|
||||
import { apply as applyMemory, lastUserText, name as pluginName } from '../lib/index.js'
|
||||
|
||||
/**
|
||||
* Build a real cordis app carrying the services this plugin injects.
|
||||
* `ToolRuntime` declares `inject = ['systemPrompt']`, so prompt comes first.
|
||||
* @returns {Promise<{ctx: Context, dir: string, done: () => void}>}
|
||||
*/
|
||||
async function app() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-memory-plugin-'))
|
||||
const ctx = new Context()
|
||||
new SystemPrompt(ctx, {})
|
||||
new ToolRuntime(ctx, {})
|
||||
return {
|
||||
ctx,
|
||||
dir,
|
||||
done: async () => {
|
||||
await ctx.fiber.dispose()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** A realistic dsh step batch: question, then two user-role injected contexts. */
|
||||
function realBatch(question) {
|
||||
return [
|
||||
{
|
||||
id: 'm1',
|
||||
role: 'user',
|
||||
source: { kind: 'user' },
|
||||
content: [{ type: 'text', text: question }],
|
||||
},
|
||||
{
|
||||
id: 'm2',
|
||||
role: 'user',
|
||||
source: { kind: 'runtime-context', form: 'snapshot' },
|
||||
content: [{ type: 'text', text: '[Working directory]\n/home/agent/project' }],
|
||||
},
|
||||
{
|
||||
id: 'm3',
|
||||
role: 'user',
|
||||
source: { kind: 'skill-catalog', form: 'catalog' },
|
||||
content: [{ type: 'text', text: '[Skills] git-release-discipline, android-cli, shared-browser' }],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
test('lastUserText picks the newest HUMAN prompt and joins only text blocks', () => {
|
||||
// Every real message carries `source.kind`; the fixtures must too, or the test
|
||||
// does not exercise the actual discrimination (see the REGRESSION block below).
|
||||
const messages = [
|
||||
{ role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: 'first' }] },
|
||||
{ role: 'assistant', source: { kind: 'model' }, content: [{ type: 'text', text: 'reply' }] },
|
||||
{
|
||||
role: 'user',
|
||||
source: { kind: 'user' },
|
||||
content: [{ type: 'tool-result' }, { type: 'text', text: 'second' }],
|
||||
},
|
||||
]
|
||||
assert.equal(lastUserText(messages), 'second')
|
||||
assert.equal(lastUserText([]), '')
|
||||
assert.equal(
|
||||
lastUserText([{ role: 'assistant', source: { kind: 'model' }, content: [{ type: 'text', text: 'x' }] }]),
|
||||
'',
|
||||
)
|
||||
})
|
||||
|
||||
// ── G6: the allowlist must be proven against REAL injected kinds ───────────
|
||||
//
|
||||
// Scope note (important): this table is **not** claimed to be exhaustive. It is
|
||||
// "every user-role injected kind declared in the currently installed harness".
|
||||
// The full `MessageSourceMap` surface is larger (25 source keys at the time of
|
||||
// writing), and more will appear. That is fine — `HUMAN_PROMPT_KINDS` is an
|
||||
// allowlist, so an unlisted future kind fails closed by construction, which is
|
||||
// the property that actually matters at runtime.
|
||||
//
|
||||
// What this table DOES prove: the names we think are injected really are, and a
|
||||
// rename in the harness breaks a test here instead of silently killing recall.
|
||||
//
|
||||
// Sources (each verified as a literal declaration, not inferred):
|
||||
// agent-instructions dsh-agent-instructions/lib/types/state.d.ts:27
|
||||
// agent-message dsh-subagent/lib/types/continuation-messages.d.ts:37
|
||||
// compact-checkpoint dsh-compaction/lib/types/checkpoint.d.ts:18
|
||||
// cordis-host-runner dsh-cordis-host-runner/lib/types/index.d.ts:13
|
||||
// dsh-session-title-llm dsh-session-title-llm/lib/types/index.d.ts:14
|
||||
// model-selection dsh-agent/lib/types/model-selection.d.ts:11
|
||||
// plan-mode dsh-plan-mode/lib/types/index.d.ts:31
|
||||
// ptc-mode dsh-tools/lib/types/ptc.d.ts:14
|
||||
// repeat-tool-reminder dsh-repeat-tool-reminder/lib/types/index.d.ts:14
|
||||
// runtime-context dsh-agent-loop/lib/types/runtime-context.d.ts:13
|
||||
// schedule dsh-schedule/lib/types/runtime.d.ts:5
|
||||
// session-reference dsh-session-reference/lib/types/types.d.ts:33
|
||||
// skill-catalog dsh-tool-skill/lib/types/index.d.ts:29
|
||||
// skill-invocation dsh-skill/lib/types/index.d.ts:131
|
||||
// subagent-settled dsh-subagent/lib/types/continuation-messages.d.ts:38
|
||||
// team-message dsh-experimental-agent-team/lib/types/types.d.ts:123
|
||||
// time-context dsh-time-context/lib/types/index.d.ts:14
|
||||
// tmux-context dsh-tmux-context/lib/types/index.d.ts
|
||||
// tool-goal dsh-tool-goal/lib/types/index.d.ts:12
|
||||
// tool-jobs dsh-tool-jobs/lib/types/index.d.ts:16
|
||||
// tool-registry dsh-tools/lib/types/index.d.ts:20
|
||||
// user-approval dsh-user-approval/lib/types/index.d.ts:14
|
||||
// working-directory dsh-working-directory/lib/types/index.d.ts
|
||||
const REAL_INJECTED_KINDS = [
|
||||
'agent-instructions',
|
||||
'agent-message',
|
||||
'compact-checkpoint',
|
||||
'cordis-host-runner',
|
||||
'dsh-session-title-llm',
|
||||
'model-selection',
|
||||
'plan-mode',
|
||||
'ptc-mode',
|
||||
'repeat-tool-reminder',
|
||||
'runtime-context',
|
||||
'schedule',
|
||||
'session-reference',
|
||||
'skill-catalog',
|
||||
'skill-invocation',
|
||||
'subagent-settled',
|
||||
'team-message',
|
||||
'time-context',
|
||||
'tmux-context',
|
||||
'tool-goal',
|
||||
'tool-jobs',
|
||||
'tool-registry',
|
||||
'user-approval',
|
||||
'working-directory',
|
||||
]
|
||||
|
||||
test('G6: every user-role injected kind in the installed harness is rejected as a query', () => {
|
||||
for (const kind of REAL_INJECTED_KINDS) {
|
||||
const batch = [
|
||||
{ role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: '真正的问题' }] },
|
||||
{ role: 'user', source: { kind }, content: [{ type: 'text', text: `[${kind}] injected body` }] },
|
||||
]
|
||||
assert.equal(
|
||||
lastUserText(batch),
|
||||
'真正的问题',
|
||||
`kind "${kind}" must be treated as injected, not as the human prompt`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('G6 mutation: "role is user ⇒ accept" must fail on every real injected kind', () => {
|
||||
// The mutation is the exact first implementation: no kind filter at all.
|
||||
const roleOnly = (messages) => {
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
if (messages[i]?.role !== 'user') continue
|
||||
const t = (messages[i].content ?? [])
|
||||
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
|
||||
.map((b) => b.text)
|
||||
.join('\n')
|
||||
.trim()
|
||||
if (t !== '') return t
|
||||
}
|
||||
return ''
|
||||
}
|
||||
let mutatedFailures = 0
|
||||
for (const kind of REAL_INJECTED_KINDS) {
|
||||
const batch = [
|
||||
{ role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: '真正的问题' }] },
|
||||
{ role: 'user', source: { kind }, content: [{ type: 'text', text: `[${kind}] injected body` }] },
|
||||
]
|
||||
assert.equal(lastUserText(batch), '真正的问题')
|
||||
if (roleOnly(batch) !== '真正的问题') mutatedFailures += 1
|
||||
}
|
||||
assert.equal(
|
||||
mutatedFailures,
|
||||
REAL_INJECTED_KINDS.length,
|
||||
`the mutation must fail on ALL ${REAL_INJECTED_KINDS.length} kinds, failed on ${mutatedFailures}`,
|
||||
)
|
||||
})
|
||||
|
||||
// ── G6b: the allowlist must NOT exclude a real human prompt ───────────────
|
||||
//
|
||||
// This is the safety half of G6, and it is the reason a strict allowlist is
|
||||
// acceptable at all. A remote prompt arrives with a DIFFERENT source key
|
||||
// (`user-rpc`) but the SAME kind (`user`):
|
||||
//
|
||||
// dsh-api-session-controller/lib/types/types.d.ts:395-400
|
||||
// 'user-rpc': { kind: 'user'; rpcId: SessionRequestId; clientTimeZone?: string; principal?: Principal }
|
||||
//
|
||||
// So `kind: 'user'` covers both the local prompt and the browser/RPC prompt.
|
||||
// Without this assertion, a future tightening of the allowlist (e.g. switching
|
||||
// to key-based filtering) could silently stop recalling for web users only.
|
||||
test('G6b: a user-rpc prompt (kind "user", extra fields) IS accepted', () => {
|
||||
const rpc = {
|
||||
role: 'user',
|
||||
source: {
|
||||
kind: 'user',
|
||||
rpcId: 'rpc-123',
|
||||
clientTimeZone: 'Asia/Shanghai',
|
||||
principal: { id: 'other-caller' },
|
||||
},
|
||||
content: [{ type: 'text', text: '远程提问:值班室分机号是多少' }],
|
||||
}
|
||||
assert.equal(
|
||||
lastUserText([rpc]),
|
||||
'远程提问:值班室分机号是多少',
|
||||
'user-rpc carries kind "user" and must be treated as a human prompt',
|
||||
)
|
||||
// And it still wins over an injected message that arrives after it.
|
||||
const batch = [
|
||||
rpc,
|
||||
{ role: 'user', source: { kind: 'runtime-context' }, content: [{ type: 'text', text: '[wd]' }] },
|
||||
]
|
||||
assert.equal(lastUserText(batch), '远程提问:值班室分机号是多少')
|
||||
})
|
||||
|
||||
// ── G7: a message with no source at all must yield '' ─────────────────────
|
||||
//
|
||||
// Pinned explicitly rather than left to inference. Reachability note: the
|
||||
// current harness makes this state unreachable — `assertMessageEventShape`
|
||||
// rejects `user/message` whose `source.kind` is not a non-empty string, and it
|
||||
// runs on BOTH the append path (dsh-session/lib/index.js:1089) and the
|
||||
// seed/restore path (:1165). So this is defense in depth against a future
|
||||
// format/migration change, not a live bug. The assertion still earns its keep:
|
||||
// it is the guard that keeps a missing `source` from silently degrading to
|
||||
// "use whatever the last user-role message says".
|
||||
test('G7: kind === undefined (and any non-string kind) yields ""', () => {
|
||||
const cases = [
|
||||
{ name: 'source missing entirely', message: { role: 'user', content: [{ type: 'text', text: 'q' }] } },
|
||||
{ name: 'source is null', message: { role: 'user', source: null, content: [{ type: 'text', text: 'q' }] } },
|
||||
{ name: 'source.kind missing', message: { role: 'user', source: {}, content: [{ type: 'text', text: 'q' }] } },
|
||||
{
|
||||
name: 'source.kind not a string',
|
||||
message: { role: 'user', source: { kind: 42 }, content: [{ type: 'text', text: 'q' }] },
|
||||
},
|
||||
{
|
||||
name: 'source.kind empty string',
|
||||
message: { role: 'user', source: { kind: '' }, content: [{ type: 'text', text: 'q' }] },
|
||||
},
|
||||
]
|
||||
for (const { name, message } of cases) {
|
||||
assert.equal(lastUserText([message]), '', `${name} must yield "" (fail closed)`)
|
||||
}
|
||||
})
|
||||
|
||||
test('G7 mutation: dropping the typeof guard admits a sourceless message', () => {
|
||||
// The mutation: `HUMAN_PROMPT_KINDS.has(undefined)` is false, so removing the
|
||||
// typeof check would NOT by itself admit undefined — the real risk is a
|
||||
// future refactor to `kind !== 'user'` negation or a role-only fallback.
|
||||
// Pin the behaviour that the guard exists to produce:
|
||||
const mutated = (messages) => {
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
if (messages[i]?.role !== 'user') continue
|
||||
// Mutation: no source check at all.
|
||||
const t = (messages[i].content ?? [])
|
||||
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
|
||||
.map((b) => b.text)
|
||||
.join('\n')
|
||||
.trim()
|
||||
if (t !== '') return t
|
||||
}
|
||||
return ''
|
||||
}
|
||||
const sourceless = [{ role: 'user', content: [{ type: 'text', text: 'q' }] }]
|
||||
assert.equal(lastUserText(sourceless), '', 'guarded implementation rejects it')
|
||||
assert.equal(mutated(sourceless), 'q', 'unguarded implementation admits it — G7 catches this')
|
||||
})
|
||||
|
||||
test('V3: systemPrompt.context exposes agent but no step messages', async () => {
|
||||
const { ctx, done } = await app()
|
||||
try {
|
||||
let keys = []
|
||||
ctx.systemPrompt.context({
|
||||
name: 'probe:v3',
|
||||
order: 210,
|
||||
text: (context) => {
|
||||
keys = Object.keys(context).sort()
|
||||
return 'probe'
|
||||
},
|
||||
})
|
||||
await ctx.systemPrompt.assemble({ agent: { id: 'a', session: {} } })
|
||||
assert.ok(keys.includes('agent'), `AssembleContext must carry agent; got ${keys.join(',')}`)
|
||||
// The negative assertion is the load-bearing one: if dsh ever adds the step
|
||||
// messages here, recall could move to this hook and this slice's whole
|
||||
// design note would be wrong.
|
||||
for (const forbidden of ['messages', 'input', 'text']) {
|
||||
assert.ok(!keys.includes(forbidden), `AssembleContext must NOT expose ${forbidden}`)
|
||||
}
|
||||
} finally {
|
||||
await done()
|
||||
}
|
||||
})
|
||||
|
||||
test('V3b: pre-step injection adds ONE attributed message after a real batch', async () => {
|
||||
const { ctx, dir, done } = await app()
|
||||
try {
|
||||
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), topK: 3 })
|
||||
await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' })
|
||||
|
||||
// Uses realBatch, not a hand-built single message: the whole lesson of the
|
||||
// regression above is that a 1- or 2-message batch is a fake batch.
|
||||
const batch = realBatch('值班室分机号 4324 是谁在值班')
|
||||
const decision = await ctx.waterfall(
|
||||
'agent/pre-step',
|
||||
{
|
||||
agent: { id: 'a', session: {} },
|
||||
messages: batch,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
() => Promise.resolve({ kind: 'enter', messages: batch }),
|
||||
)
|
||||
assert.equal(decision.kind, 'enter')
|
||||
assert.equal(decision.messages.length, batch.length + 1, 'batch + exactly one injected recall')
|
||||
const injected = decision.messages[batch.length]
|
||||
assert.equal(injected.role, 'user', 'injected context is a user message')
|
||||
assert.equal(injected.source.kind, pluginName)
|
||||
assert.equal(injected.source.form, 'snapshot')
|
||||
assert.ok(injected.content.map((b) => b.text ?? '').join('').includes('4324'))
|
||||
} finally {
|
||||
await done()
|
||||
}
|
||||
})
|
||||
|
||||
test('V3b mutation: with recall silenced the batch stays un-augmented', async () => {
|
||||
const { ctx, dir, done } = await app()
|
||||
try {
|
||||
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), topK: 3 })
|
||||
await mem.commit({ text: '值班室分机号 4324,值班人 老周' })
|
||||
mem.contextForStep = () => ''
|
||||
const input = createUserMessage({
|
||||
content: [{ type: 'text', text: '值班室分机号 4324' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
const decision = await ctx.waterfall(
|
||||
'agent/pre-step',
|
||||
{
|
||||
agent: { id: 'a', session: {} },
|
||||
messages: [input],
|
||||
turn: 1,
|
||||
step: 1,
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
() => Promise.resolve({ kind: 'enter', messages: [input] }),
|
||||
)
|
||||
assert.equal(decision.messages.length, 1, 'no injection when recall yields nothing')
|
||||
} finally {
|
||||
await done()
|
||||
}
|
||||
})
|
||||
|
||||
test('V3b: a rejected step is passed through untouched', async () => {
|
||||
const { ctx, dir, done } = await app()
|
||||
try {
|
||||
applyMemory(ctx, { path: join(dir, 'm.db') })
|
||||
const decision = await ctx.waterfall(
|
||||
'agent/pre-step',
|
||||
{
|
||||
agent: { id: 'a', session: {} },
|
||||
messages: [],
|
||||
turn: 1,
|
||||
step: 1,
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
() => Promise.resolve({ kind: 'reject' }),
|
||||
)
|
||||
assert.equal(decision.kind, 'reject', 'a rejection must not be converted into an enter')
|
||||
} finally {
|
||||
await done()
|
||||
}
|
||||
})
|
||||
|
||||
test('V4: stablemem_commit registers with a schema and executes end to end', async () => {
|
||||
const { ctx, dir, done } = await app()
|
||||
try {
|
||||
const mem = applyMemory(ctx, { path: join(dir, 'm.db') })
|
||||
const schemas = ctx.tools.schemas()
|
||||
const tool = schemas.find((entry) => entry.name === 'stablemem_commit')
|
||||
assert.ok(tool, 'tool must be visible to the model')
|
||||
assert.ok(tool.description.length > 0, 'tool must carry a description')
|
||||
assert.ok(tool.parameters?.properties?.text, 'tool must declare its text parameter')
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: 'c1',
|
||||
name: 'stablemem_commit',
|
||||
arguments: { text: 'etcd 集群端口 2379', source: 'test' },
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
assert.equal(result.isError, false, JSON.stringify(result))
|
||||
assert.ok(String(result.value).includes('Committed'), result.value)
|
||||
assert.equal(result.content[0].type, 'text', 'render must produce a text block')
|
||||
assert.equal(mem.store.countBlocks(), 1)
|
||||
} finally {
|
||||
await done()
|
||||
}
|
||||
})
|
||||
|
||||
test('V5: disposing the fiber closes the store and releases the file', async () => {
|
||||
const { ctx, dir, done } = await app()
|
||||
const path = join(dir, 'm.db')
|
||||
const mem = applyMemory(ctx, { path })
|
||||
await mem.commit({ text: 'V5 句柄释放块' })
|
||||
assert.equal(mem.store.isOpen, true)
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
assert.equal(mem.store.isOpen, false, 'fiber disposal must close the handle')
|
||||
|
||||
// Strongest proof: reopen the same file for writing.
|
||||
const { BlockStore } = await import('../lib/store.js')
|
||||
const reopened = new BlockStore({ path }).open()
|
||||
assert.equal(reopened.countBlocks(), 1)
|
||||
reopened.close()
|
||||
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
void done
|
||||
})
|
||||
|
||||
test('config.inject:false registers the tool but no pre-step listener', async () => {
|
||||
const { ctx, dir, done } = await app()
|
||||
try {
|
||||
applyMemory(ctx, { path: join(dir, 'm.db'), inject: false })
|
||||
const input = createUserMessage({
|
||||
content: [{ type: 'text', text: 'anything' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
const decision = await ctx.waterfall(
|
||||
'agent/pre-step',
|
||||
{
|
||||
agent: { id: 'a', session: {} },
|
||||
messages: [input],
|
||||
turn: 1,
|
||||
step: 1,
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
() => Promise.resolve({ kind: 'enter', messages: [input] }),
|
||||
)
|
||||
assert.equal(decision.messages.length, 1, 'injection must be off')
|
||||
assert.ok(ctx.tools.schemas().some((s) => s.name === 'stablemem_commit'), 'tool still present')
|
||||
} finally {
|
||||
await done()
|
||||
}
|
||||
})
|
||||
|
||||
// ── REGRESSION: auto-recall died on a realistic dsh batch ──────────────────
|
||||
//
|
||||
// Found by running the plugin in a real headless profile, not by unit test.
|
||||
// The unit test above passes because it builds a 2-message batch; a REAL step
|
||||
// batch has additional user-role messages appended AFTER the question:
|
||||
//
|
||||
// [ user question ][ runtime-context snapshot ][ skill catalog reminder ]
|
||||
//
|
||||
// `lastUserText` used to return the last `role === 'user'` message, which is the
|
||||
// skill catalog — so every real turn used a list of skill names as its recall
|
||||
// query and matched nothing. Auto-recall was 100% dead in production while all
|
||||
// checkpoints were green.
|
||||
//
|
||||
// The fix: filter by `source.kind`, not by role alone.
|
||||
|
||||
test('REGRESSION: lastUserText picks the real question out of a 3-part batch', () => {
|
||||
const extracted = lastUserText(realBatch('值班室分机号是多少'))
|
||||
assert.equal(
|
||||
extracted,
|
||||
'值班室分机号是多少',
|
||||
`must return the human question, not the injected context; got ${JSON.stringify(extracted)}`,
|
||||
)
|
||||
})
|
||||
|
||||
test('REGRESSION: auto-recall hits on a realistic batch (the bug was 0 hits)', async () => {
|
||||
const { ctx, dir, done } = await app()
|
||||
try {
|
||||
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), topK: 3 })
|
||||
await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' })
|
||||
|
||||
const decision = await ctx.waterfall(
|
||||
'agent/pre-step',
|
||||
{
|
||||
agent: { id: 'a', session: {} },
|
||||
messages: realBatch('值班室分机号是多少'),
|
||||
turn: 1,
|
||||
step: 1,
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
() => Promise.resolve({ kind: 'enter', messages: realBatch('值班室分机号是多少') }),
|
||||
)
|
||||
assert.equal(decision.messages.length, 4, '3 batch messages + 1 injected recall')
|
||||
const injected = decision.messages[3]
|
||||
const text = injected.content.map((b) => b.text ?? '').join('')
|
||||
assert.ok(
|
||||
text.includes('4324'),
|
||||
`recall must find the note; injected was ${JSON.stringify(text.slice(0, 100))}`,
|
||||
)
|
||||
} finally {
|
||||
await done()
|
||||
}
|
||||
})
|
||||
|
||||
test('REGRESSION mutation: falling back to "last role=user" must drop recall to 0', async () => {
|
||||
const { ctx, dir, done } = await app()
|
||||
try {
|
||||
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), topK: 3 })
|
||||
await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' })
|
||||
|
||||
// The mutation: reintroduce the old extraction rule.
|
||||
const oldRule = (messages) => {
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
if (messages[i]?.role !== 'user') continue
|
||||
const t = (messages[i].content ?? [])
|
||||
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
|
||||
.map((b) => b.text)
|
||||
.join('\n')
|
||||
.trim()
|
||||
if (t !== '') return t
|
||||
}
|
||||
return ''
|
||||
}
|
||||
const batch = realBatch('值班室分机号是多少')
|
||||
const mutatedQuery = oldRule(batch)
|
||||
// `recall` became async in phase B group A. Await it: a bare Promise has no
|
||||
// `.length`, and `undefined < undefined` is false, so this assertion would
|
||||
// silently pass while measuring nothing.
|
||||
const mutatedHits = await mem.recall(mutatedQuery)
|
||||
const realHits = await mem.recall(lastUserText(batch))
|
||||
|
||||
// Numbers on the record: the old rule must lose every hit.
|
||||
assert.ok(
|
||||
mutatedHits.length < realHits.length,
|
||||
`mutation must lose hits: old-rule=${mutatedHits.length}, fixed=${realHits.length}`,
|
||||
)
|
||||
assert.equal(mutatedHits.length, 0, `old rule must score 0 hits, got ${mutatedHits.length}`)
|
||||
assert.ok(realHits.length >= 1, 'the fixed rule must find at least one')
|
||||
} finally {
|
||||
await done()
|
||||
}
|
||||
})
|
||||
306
test/recall-accounting.test.js
Normal file
306
test/recall-accounting.test.js
Normal file
@ -0,0 +1,306 @@
|
||||
/**
|
||||
* D2 · 每一次丢弃都必须可计数。
|
||||
*
|
||||
* ## 病
|
||||
*
|
||||
* 一条候选可以从三个地方消失,而在本次改动之前只有前两个(空间过滤、宽度
|
||||
* 不符)会被报告:
|
||||
*
|
||||
* · MinScore 门 —— 它自己的向量分不够
|
||||
* · 结构性门 —— 既没有可用的向量分,也没有符号命中
|
||||
* · topK 截断 —— 过了所有门,但排在调用方要的条数之外
|
||||
*
|
||||
* 后三个都是裸 `continue`,不进任何计数器。于是「6 条候选留下 1 条」与
|
||||
* 「库里只有 1 条」在对外读数上完全一样:`skipped=0 skippedDim=0`。
|
||||
* 实测(改动前):6 条块、minScore=0.99 → hits=0,两个计数都是 0。
|
||||
*
|
||||
* 运维看 `skipped=0` 会得出「没有任何东西被排除」的结论,而真相可能是阈值
|
||||
* 把整批都挡了 —— 一个应该调阈值的信号被读成了「库是空的」。
|
||||
*
|
||||
* ## 判据形态
|
||||
*
|
||||
* 三条路径各造一个 fixture,断**各自**的计数。另加一条不变式:
|
||||
* 候选池 = 命中 + 三个下游计数 + 上游两个计数,等式必须成立 —— 否则将来再
|
||||
* 加一条静默丢弃路径,等式就会破。
|
||||
*
|
||||
* @module dsh-memory/test/recall-accounting
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { BlockStore } from '../lib/store.js'
|
||||
import { recallBlocks, recallBlocksHybrid } from '../lib/recall.js'
|
||||
|
||||
const DIM = 8
|
||||
const FP = 'd2:accounting'
|
||||
const unit = (v) => { const n = Math.hypot(...v); return n === 0 ? v : v.map((x) => x / n) }
|
||||
const axis = (i) => unit(Array.from({ length: DIM }, (_, k) => (k === i ? 1 : 0)))
|
||||
|
||||
/** 6 条块,各占一轴;query 落在轴 0,所以 cosine 已知且互不相同。 */
|
||||
function seeded() {
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
const ids = []
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
ids.push(store.putBlock({ text: `块 ${i} 的正文`, vector: axis(i), fingerprint: FP }).id)
|
||||
}
|
||||
const query = unit([1, 0.5, 0.2, 0, 0, 0, 0, 0])
|
||||
const scores = new Map(ids.map((id, i) => [id, i === 0 ? 0.95 : 0.1 + i * 0.01]))
|
||||
return { store, ids, query, scores }
|
||||
}
|
||||
|
||||
test('D2: candidates rejected by MinScore are counted, not silently dropped', () => {
|
||||
const { store, query, scores } = seeded()
|
||||
try {
|
||||
const hits = recallBlocks(store, query, { topK: 6, fingerprint: FP, dimension: DIM, vectorScores: scores, minScore: 0.9 })
|
||||
assert.equal(hits.length, 1, 'precondition: only the 0.95 candidate clears 0.9')
|
||||
assert.equal(
|
||||
hits.skippedScore,
|
||||
5,
|
||||
'D2: five candidates failed the score gate and must be REPORTED as such. '
|
||||
+ 'Before this counter existed the result was indistinguishable from a '
|
||||
+ 'one-block space, which sends the operator looking at the wrong subsystem.',
|
||||
)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('D2: candidates with neither vector score nor symbol hit are counted separately', () => {
|
||||
const { store, ids, query } = seeded()
|
||||
try {
|
||||
// A candidate reaches the STRUCTURAL gate when it IS in the pool but has no
|
||||
// usable vector score (absent, or <= 0) and no symbol match. A cosine of
|
||||
// exactly 0 is the realistic trigger: it is what a candidate orthogonal to
|
||||
// the query gets, and also what centring produces in a degenerate space.
|
||||
const scores = new Map([[ids[0], 0.95], [ids[1], 0.8], [ids[2], 0]])
|
||||
const hits = recallBlocks(store, 'zzz-no-symbol-match', {
|
||||
topK: 6, fingerprint: FP, dimension: DIM, vectorScores: scores, minScore: 0.5,
|
||||
})
|
||||
assert.equal(hits.length, 2, 'precondition: only the two positive scores survive')
|
||||
assert.equal(hits.skippedScore, 0, 'precondition: nothing failed the threshold')
|
||||
assert.equal(
|
||||
hits.skippedNoEvidence,
|
||||
1,
|
||||
'D2: a candidate that matched neither route must be counted under its own name — '
|
||||
+ '"nothing to score" and "scored too low" have opposite remedies (re-embed vs. '
|
||||
+ 'lower the threshold). Blocks that never entered the pool are a different, '
|
||||
+ 'upstream fact and must not be folded in here.',
|
||||
)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('D2: candidates cut by the caller topK are counted as truncated', () => {
|
||||
const { store, query, scores } = seeded()
|
||||
try {
|
||||
const all = recallBlocks(store, query, { topK: 6, fingerprint: FP, dimension: DIM, vectorScores: scores, minScore: 0.05 })
|
||||
const top2 = recallBlocks(store, query, { topK: 2, fingerprint: FP, dimension: DIM, vectorScores: scores, minScore: 0.05 })
|
||||
assert.equal(all.length, 6, 'precondition: all six clear a 0.05 threshold')
|
||||
assert.equal(top2.length, 2, 'precondition: topK truncates')
|
||||
assert.equal(top2.skippedScore, 0, 'nothing failed the score gate here')
|
||||
assert.equal(
|
||||
top2.truncated,
|
||||
4,
|
||||
'D2: the four that fell outside topK must be reported. Truncation is not a '
|
||||
+ 'defect, but leaving it uncounted breaks the reconciliation of '
|
||||
+ 'candidates = hits + counters.',
|
||||
)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('D2: hits plus every counter reconciles with the candidate pool', () => {
|
||||
const { store, query, scores } = seeded()
|
||||
try {
|
||||
const hits = recallBlocks(store, query, { topK: 3, fingerprint: FP, dimension: DIM, vectorScores: scores, minScore: 0.15 })
|
||||
const accounted = hits.length
|
||||
+ hits.skippedScore
|
||||
+ hits.skippedNoEvidence
|
||||
+ hits.skippedUnscoreable
|
||||
+ hits.truncated
|
||||
+ hits.skipped
|
||||
+ hits.skippedDim
|
||||
assert.equal(
|
||||
accounted,
|
||||
6,
|
||||
`D2: every candidate must land in exactly one bucket; ${accounted} of 6 accounted for. `
|
||||
+ 'A shortfall means a new silent drop path was added without a counter.',
|
||||
)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── D2 的第二个洞:在候选池存在之前就消失的块 ──────────────────────────────
|
||||
//
|
||||
// 07f8438 给三个「门」加了计数,但候选池是三处之外还有一段:`scoreVectorCandidates`
|
||||
// 里算不出分数的行直接 continue —— 坏 JSON、宽度不符、非有限分量、零模,以及
|
||||
// C4 之后新增的一种:**中心化把向量抹成尘埃**。这些块压根不进 vectorScores,
|
||||
// 于是 recallBlocks 装配候选池时(`if (!symbolMatch && !vectorSource) continue`)
|
||||
// 也看不到它们。
|
||||
//
|
||||
// 实测(3 条完全相同的块 + 中心化生效):
|
||||
// hits=0 skipped=0 skippedDim=0 skippedScore=0 skippedNoEvidence=0 truncated=0
|
||||
// —— 整个空间凭空消失,六个计数器全读 0。这正是 D2 自己禁止的静默分支,
|
||||
// 只是位置比当初看的更深一层。
|
||||
//
|
||||
// 这条判据同时钉住两件事:计数非零,以及「命中 + 全部计数」仍能对上候选池。
|
||||
test('D2: a vector that centring collapses is counted, not silently lost', async () => {
|
||||
const DIM = 4
|
||||
const FP = 'd2:unscoreable'
|
||||
const unit = (v) => { const n = Math.hypot(...v); return n === 0 ? v : v.map((x) => x / n) }
|
||||
const blockVec = unit([1, 0.2, 0.1, 0])
|
||||
const queryVec = unit([1, 0.9, 0.1, 0])
|
||||
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
try {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
store.putBlock({ text: `重复块 ${i}`, vector: blockVec, fingerprint: FP })
|
||||
}
|
||||
const built = store.rebuildCentroid(FP)
|
||||
assert.ok(built.meanLength > 0.999, `precondition: identical blocks give a unit mean; got ${built.meanLength}`)
|
||||
assert.equal(built.isAnomalous, true, 'precondition: centring must actually engage')
|
||||
|
||||
const embedder = {
|
||||
async embedText() { return queryVec },
|
||||
info: () => ({ fingerprint: FP, dimension: DIM }),
|
||||
}
|
||||
const hits = await recallBlocksHybrid(store, embedder, 'zzz-no-symbol-match', { topK: 10, minScore: 0 })
|
||||
|
||||
assert.equal(hits.length, 0, 'precondition: every block is centred onto the mean and dropped')
|
||||
assert.equal(
|
||||
hits.skippedUnscoreable,
|
||||
3,
|
||||
'D2: these blocks were dropped before the candidate pool existed, so no other '
|
||||
+ 'counter can see them. Reporting 0 here is what made a whole space vanish '
|
||||
+ 'while the log read "skipped=0".',
|
||||
)
|
||||
|
||||
const accounted = hits.length + hits.skipped + hits.skippedDim + hits.skippedScore
|
||||
+ hits.skippedNoEvidence + hits.skippedUnscoreable + hits.truncated
|
||||
assert.equal(
|
||||
accounted,
|
||||
3,
|
||||
`D2: hits + every counter must equal the space's blocks; ${accounted} of 3.`,
|
||||
)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── D1: 召回主路径不得因向量侧故障抛错 ──────────────────────────────────────
|
||||
//
|
||||
// 实测出来的真缺口:把「损坏向量」的处理从 skip 改成 throw 之后,
|
||||
// phase-b-h 137 条、phase-b-a 38 条、recall-accounting 5 条 —— 145 条**全绿**。
|
||||
// 也就是说「损坏的向量载荷不能把整轮召回打挂」这件事,此前没有任何判据守着。
|
||||
//
|
||||
// (父早期的覆盖率矩阵按条目 ID grep 测试名,把 C6/W1/F1 也标成零覆盖;实测
|
||||
// 那些其实有判据,只是没按 ID 命名。D1 是四项里唯一真的零覆盖。)
|
||||
//
|
||||
// 决策 §0.4 的原话是「向量侧的故障不得让主路径抛错」:一块脏数据不能把整轮
|
||||
// 记忆检索变成异常,模型那一侧看到的就是完全失忆。所以这里直接往库里塞一行
|
||||
// 坏 payload,然后要求召回照常返回。
|
||||
test('D1: a corrupt vector payload cannot take the recall path down', async () => {
|
||||
const DIM = 4
|
||||
const FP = 'd1:corrupt'
|
||||
const unit = (v) => { const n = Math.hypot(...v); return n === 0 ? v : v.map((x) => x / n) }
|
||||
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
try {
|
||||
const healthy = store.putBlock({ text: '值班室的分机号是 4324', vector: unit([1, 0.1, 0, 0]), fingerprint: FP })
|
||||
const broken = store.putBlock({ text: '一条被写坏向量的块', vector: unit([1, 0.2, 0, 0]), fingerprint: FP })
|
||||
// ★ Two guards narrow what "corrupt" can even mean here, both measured:
|
||||
// · the DDL enforces `json_valid(vector)` — a syntactically broken payload is
|
||||
// refused outright (`ERR_SQLITE_ERROR: malformed JSON`), so it cannot reach
|
||||
// the read path;
|
||||
// · a trigger re-derives `vector_dim` from the payload, so a value of the wrong
|
||||
// LENGTH is filtered out in SQL before scoring ever sees it.
|
||||
// What survives both guards and still cannot be scored is an array of the right
|
||||
// length whose elements are not numbers — exactly what a provider that switched
|
||||
// its encoding (or a migration that stringified values) would leave behind.
|
||||
store.raw().prepare('UPDATE memory_blocks SET vector = ? WHERE id = ?')
|
||||
.run('["a","b","c","d"]', broken.id)
|
||||
|
||||
const embedder = {
|
||||
async embedText() { return unit([1, 0.15, 0, 0]) },
|
||||
info: () => ({ fingerprint: FP, dimension: DIM }),
|
||||
}
|
||||
|
||||
let hits
|
||||
try {
|
||||
hits = await recallBlocksHybrid(store, embedder, '分机号是多少', { topK: 5 })
|
||||
} catch (error) {
|
||||
assert.fail(
|
||||
'D1: a single corrupt vector payload threw out of the recall path '
|
||||
+ `(${error.message}). Decision §0.4: the vector side failing must not take `
|
||||
+ 'the main path down — one bad row would otherwise cost the whole turn its memory.',
|
||||
)
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
hits.some((hit) => hit.id === healthy.id),
|
||||
'D1: the healthy block must still be recalled while a corrupt row sits next to it',
|
||||
)
|
||||
assert.equal(
|
||||
hits.skippedUnscoreable,
|
||||
1,
|
||||
'D1/D2: the corrupt row is skipped AND counted — silent skipping is the other half '
|
||||
+ 'of this defect.',
|
||||
)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── 窄读等价:候选池的来源换了一条 SQL,但看到的东西必须一样 ─────────────────
|
||||
//
|
||||
// `recallBlocks` 装配候选池时要把「符号命中」和「向量候选」并起来,为此它要遍历
|
||||
// 全表。原来用 `allBlocks(-1)`(`SELECT *`),实测在 8000 块 × 512 维时把那 79MB
|
||||
// 向量文本整份物化了一遍 —— 而它只拿向量列判断「这块有没有向量」。
|
||||
//
|
||||
// 换成 `symbolCandidates()` 后,8000 块的召回从 624.0ms 降到 409.6ms(-34%)。
|
||||
// 性能改动最容易悄悄改变语义,所以这里钉住等价本身:
|
||||
// 1. 行集与顺序完全一致(同一条 ORDER BY)
|
||||
// 2. 每一条的 has_vector 与旧谓词 `String(vector ?? '') !== ''` 逐条相同
|
||||
//
|
||||
// ★ 有一个已知例外,不在这里断言:字面量字符串 'null' 在旧谓词下算「有向量」,
|
||||
// 而 `vectorCandidates` 把它当「没有」。这个分歧早于本次改动,不在本判据里
|
||||
// 悄悄改掉 —— 改它就是另一件事,要有自己的判据。
|
||||
test('narrow read: symbolCandidates() sees exactly what allBlocks(-1) saw', () => {
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
try {
|
||||
const fp = 'narrow:equiv'
|
||||
const withVector = store.putBlock({ text: '有向量的块 4324', vector: [1, 0, 0, 0], fingerprint: fp })
|
||||
const noVector = store.putBlock({ text: '没有向量的块 4324' })
|
||||
const emptyVector = store.putBlock({ text: '向量为空串的块 4324', vector: [0.5, 0.5, 0, 0], fingerprint: fp })
|
||||
store.raw().prepare("UPDATE memory_blocks SET vector = '' WHERE id = ?").run(emptyVector.id)
|
||||
|
||||
const wide = store.allBlocks(-1)
|
||||
const narrow = store.symbolCandidates()
|
||||
|
||||
assert.deepEqual(
|
||||
narrow.map((row) => row.id),
|
||||
wide.map((row) => row.id),
|
||||
'the narrow read must return the same rows in the same order — the pool is a Map, '
|
||||
+ 'and insertion order is what the caller iterates',
|
||||
)
|
||||
|
||||
const wideById = new Map(wide.map((row) => [row.id, row]))
|
||||
for (const row of narrow) {
|
||||
const old = wideById.get(row.id)
|
||||
assert.equal(
|
||||
row.has_vector,
|
||||
String(old.vector ?? '') !== '' ? 1 : 0,
|
||||
`has_vector must reproduce the predicate it replaced, for block ${row.id}`,
|
||||
)
|
||||
}
|
||||
|
||||
assert.equal(narrow.find((r) => r.id === withVector.id).has_vector, 1, 'a real vector counts')
|
||||
assert.equal(narrow.find((r) => r.id === noVector.id).has_vector, 0, 'no vector does not')
|
||||
assert.equal(narrow.find((r) => r.id === emptyVector.id).has_vector, 0, 'an empty payload does not')
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
201
test/store.test.js
Normal file
201
test/store.test.js
Normal file
@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 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()
|
||||
})
|
||||
Reference in New Issue
Block a user