`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.