Files
HologramAgent-DSH-Memory-Port/package.json
jianf 1309afec93 fix(memory): wire the computed query vector into recall scoring (P1)
`recallBlocksHybrid` computed `queryVector`, verified its length against the
declared space, and then threw it away:

    return withDiagnosis(recallBlocks(store, query, scoped), 'vector_ready_unused')

`scoped` carries only `topK` / `signal` / `fingerprint` / `dimension`. So every
V1–V7 scoring branch inside `recallBlocks` was live code reachable ONLY from
unit tests that hand-built a `vectorScores` Map — `vectorScores:` appeared 10
times in test/phase-b-a.test.js and ZERO times under lib/. Production recall was
pure symbol/FTS: the whole vector half of phase B was unreachable, and the only
symptom was a diagnostic label nobody was watching.

Why BOTH options are now passed (structural, not stylistic)

  · `vectorScores` alone reaches only the V1 exact-band tie adjustment (:438).
    The fused candidate assembly at :233 is gated on `Number.isFinite(minScore)`,
    so without a threshold the vector route can never admit a block the symbol
    path missed — which is exactly what V5 exists for.
  · `minScore` alone does nothing: :299 additionally requires `vectorScores` to
    BE a Map before it builds vector candidates.
One seam, three guards. Half of it is a no-op, which is why measuring the
half-wiring mutation below matters.

Scheme A, and why not B

A: keep the existing API (`vectorScores: Map<string, number>` + `minScore`), and
   have `recallBlocksHybrid` score the query vector against the space's stored
   vectors before calling `recallBlocks` once.
B: change `recallBlocks`'s signature to accept a `queryVector` and score inside.

Chosen: A. B would touch a signature that phase A's golden fixture and nine call
sites depend on being synchronous and vector-free (`recallBlocks` MUST NOT return
a thenable — A1 pins it structurally), and it would move cosine arithmetic into a
function whose contract is literally "store + query text". A leaves
`recallBlocks` byte-untouched: the seam it already exposes is driven, so A2's
45-case golden comparison cannot drift. Both routes must also be fused in ONE
scoring pass anyway (V5 admits vector-only candidates; V7 forbids truncating
before fusion), so "call twice and merge" was never an option.

Cost, measured before choosing (this box, 2000 blocks x 512 dims)

  narrow `SELECT id, vector`    21 ms   (vs 70 ms for `allBlocks(-1)`)
  parse + cosine over 2000      60 ms
  ⇒ ~81 ms per recall; at 8000 blocks ~310 ms.

Affordable because it runs once per TURN (pre-step), not per candidate, and
because the scan is bounded by SQL: `fingerprint = ?` and `vector_dim = ?` are
pushed into the store so rows from a retired space or a wrong width are never
decoded. A phase-A store (no embedded blocks at all) pays one indexed SELECT
returning no rows. It does not add a new order of cost either: the fused path
already walks every block for symbol union (`allBlocks(-1)`); this replaces that
blind decode with a narrow one for the vector half. The remaining ceiling is a
single very large space — a number to measure before raising topK, not a reason
to ship a path that never runs. Fail-closed per §0.2/F5: blocks with an empty
fingerprint are deliberately not scored.

New diagnostic modes, both non-faults

  `hybrid`           the healthy wired path (symbol + vector fused). Explicit
                     `case` in `degradationNotice` because `default:` exists to
                     make an UNKNOWN mode say "unknown, not absent" — routing the
                     healthiest mode through it would print a false degradation
                     notice on every turn that had a vector hit.
  `no_vector_space`  a space is configured and the embedder answered, but no
                     block in it carries a usable vector (fresh space). Says
                     "nothing was scored" without claiming a provider failure.
                     An empty Map must NOT be passed to the fused route: it would
                     open candidate assembly and score symbol matches as
                     `0.7*0 + 0.3*coverage`, the V2 regression.

The criterion (test/phase-b-p1-vector-wiring.test.js)

End-to-end, because a unit test cannot see this bug: the defect is that nobody
passes the options, so asserting the options work proves nothing. The chain is
real sqlite file → real `apply()`/`commit()` write path (no hand-built vectors,
no injected embedder object: the plugin's own HTTP adapter is used) → real
`recallBlocksHybrid` → real headless `dsh --profile headless` with the shipped
`cordis.patch.yml` loop overlay (so P0 still applies) → the persisted
`session.v4.jsonl.zstd` → the stub LLM's answer, which is a pure function of the
system prompt it received.

Corpus, MEASURED before any assertion was written. Deterministic keyword-family
projection into 8 unit axes: family 0 is the target's vocabulary, families 1..7
are one axis per distractor, unmapped text → zero vector. Query
「上次说的那个四位数是什么来着?」:

  symbol path hits (target)   0     ← precondition 1
  hand-built vectorScores top1 = target, score 0.70   ← precondition 2
  query shares no CJK 2-gram with any block

Both preconditions are asserted in-suite (P1-0) so a later corpus edit cannot
make the e2e criterion unfalsifiable. An earlier attempt used the real
chinese-clip-vit-b16 model; its top-1 for the same kind of query was an unrelated
block (0.83 "入职培训"), so that corpus could never have gone green — the fake
embedder makes the question decidable instead of probabilistic.

The access code is `QP47-812` rather than a bare number so the stub's extractor
(`[A-Z]{2}\d{2}-\d{3}`) cannot collide with anything else in the prompt.

Born red against c1f7e06 (clean `git archive` of that commit, same test file):

  P1: the answer must come from block memory through the vector route.
    expected: QP47-812
    actual:   "未知"
    symbol-path hits: 0 (must be 0)
    recall diagnosis: degraded=vector_ready_unused hits=0
    recall section injected: (none)

Mutations — three, each a real edit to lib/recall.js, each restored after:

  M1 drop the wiring      `recallBlocks(store, query, scoped)`  → P1 red
  M2 half the seam        `minScore = undefined`                → P1 red
  M3 empty the scan       `const rows = []`                     → A3d red

  M1 raw: not ok 2 - P1: a semantic-only question is answered from block
          memory via the vector route
            expected: QP47-812
            actual:   "未知"
            recall diagnosis: degraded=hybrid hits=0
  M2 raw: identical text — and that is the point. Passing `vectorScores` alone
          keeps the new `hybrid` label looking healthy while the answer is still
          empty, because `minScore` is what opens the fused candidate assembly.
  M3 raw: Expected values to be strictly equal — `hybrid` unreachable, so the
          label is evidence rather than decoration.

Restoration is verified by hash, not by eye: `sha256sum lib/recall.js` is
8eb0faa660c973ea5114268001d49cf43f83ef92b2945897cfc6f7512541a498 before and
after every mutation, and `git diff` (staged vs worktree) is empty.

Test updates forced by the wiring (not cosmetic)

  A3c   the old assertion was "a healthy embedder reports vector_ready_unused,
        NOT hybrid". That was correct while the vector was unused; over a corpus
        with no vectors in the active space the honest label is now
        `no_vector_space`, and A3d pins `hybrid` once a vector exists.
  A3c†  replaced a test that only compared two local string literals (it could
        never fail for a real reason) with a mutation of the actual scoring pass.
  D3b   `hybrid` instead of the group-A stage label.
  index the two new modes are added to ORDINARY_DEGRADATIONS, so a healthy wired
        recall does not log a warning every turn.

Not in this slice: C4 (centering) is the next layer on this exact path and
deliberately not done inline, so its mutation stays citable. V6 and C5 untouched.

旁证 (not acceptance): npm test 268/268, npm run check 12/12.
2026-11-05 00:07:29 +08:00

46 lines
1.3 KiB
JSON

{
"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/loop.js",
"lib/loop/*.js",
"lib/types/*.d.ts"
],
"engines": {
"node": ">=22.5.0"
},
"scripts": {
"test": "node --test test/store.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/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
}