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.
This commit is contained in:
19
lib/index.js
19
lib/index.js
@ -79,14 +79,19 @@ export const inject = ['systemPrompt', 'tools']
|
||||
/**
|
||||
* Degradations that are NOT faults.
|
||||
*
|
||||
* `ok` is healthy. `no_embedder` is the SHIPPING DEFAULT (a standing
|
||||
* configuration fact, the same judgement W11e made for the model-facing notice),
|
||||
* `aborted` is a deliberate stop, and `vector_ready_unused` is a build stage.
|
||||
* None of them justifies a warn on every turn; all of them are still worth a
|
||||
* line when `debugLog` is on. Everything else — `embed_error`, and any mode a
|
||||
* future branch invents — is a real fault and speaks even with `debugLog` off.
|
||||
* `ok` is healthy. `hybrid` is the healthy path once vectors participate (P1),
|
||||
* `no_embedder` is the SHIPPING DEFAULT (a standing configuration fact, the same
|
||||
* judgement W11e made for the model-facing notice), `aborted` is a deliberate
|
||||
* stop, `no_vector_space` means the space exists but holds no comparable vectors
|
||||
* (a fresh space — a data state, not an incident), and `vector_ready_unused` is
|
||||
* the group-A build stage label, kept so an older build's log line still reads.
|
||||
* None of them justifies a warn on every turn; all of them are still worth a line
|
||||
* when `debugLog` is on. Everything else — `embed_error`, and any mode a future
|
||||
* branch invents — is a real fault and speaks even with `debugLog` off.
|
||||
*/
|
||||
const ORDINARY_DEGRADATIONS = new Set(['ok', 'no_embedder', 'aborted', 'vector_ready_unused'])
|
||||
const ORDINARY_DEGRADATIONS = new Set([
|
||||
'ok', 'hybrid', 'no_embedder', 'aborted', 'no_vector_space', 'vector_ready_unused',
|
||||
])
|
||||
|
||||
/**
|
||||
* Logger for one plugin instance: `ctx.logger` when the cordis build has one,
|
||||
|
||||
196
lib/recall.js
196
lib/recall.js
@ -46,6 +46,21 @@
|
||||
// ported from go: internal/memory/block_recall.go:263.
|
||||
const largeTopK = 2000
|
||||
|
||||
/**
|
||||
* Default vector-route admission threshold for the fused recall path.
|
||||
*
|
||||
* Ported from Go `blockRecallMinScore` (internal/agent/core/toolcall.go:1405),
|
||||
* which is the value production HA actually passes. Its comment there records
|
||||
* why the threshold must NOT be 0: without it, recall on a query his gograph
|
||||
* has nothing on still returns high-scoring noise, and abstention (`AbstainCheck`)
|
||||
* becomes the only thing standing between the model and a confident wrong answer.
|
||||
*
|
||||
* ★ This is an admission gate for the PURE VECTOR route only. `recallBlocks`
|
||||
* applies it per path (see the V5 block), so an exact pin and a symbol hit are
|
||||
* unaffected — that asymmetry is the whole point of V5 and is pinned separately.
|
||||
*/
|
||||
export const DEFAULT_MIN_SCORE = 0.5
|
||||
|
||||
/**
|
||||
* Extract high-information symbols from a query.
|
||||
*
|
||||
@ -550,7 +565,7 @@ function withSkipped(hits, skipped, skippedDim = 0) {
|
||||
*
|
||||
* @param {Array<object>} hits
|
||||
* @param {string|null} mode one of `no_embedder` / `embed_error` / `aborted` /
|
||||
* `vector_ready_unused` / null
|
||||
* `no_vector_space` / `vector_ready_unused` / null
|
||||
* @param {Error} [error]
|
||||
* @returns {Array<object>} the same array
|
||||
*/
|
||||
@ -592,10 +607,17 @@ function withDiagnosis(hits, mode, error) {
|
||||
/**
|
||||
* Vectors-assisted recall.
|
||||
*
|
||||
* Phase B group A ships only the *seam*, not the fusion: with no embedder (or a
|
||||
* failing one) this is exactly `recallBlocks`. Group V fills in
|
||||
* `options.vectorScores`; until then the vector call is deliberately not wired
|
||||
* into scoring, so this wrapper cannot perturb phase A behaviour.
|
||||
* With no embedder (or a failing one) this is exactly `recallBlocks`. With a
|
||||
* healthy one it scores the query vector against the space's stored vectors and
|
||||
* hands `recallBlocks` BOTH `vectorScores` and `minScore`, which together open
|
||||
* the fused candidate assembly, the V1 exact-band tie adjustment, the V2 weighted
|
||||
* blend and the V5 asymmetric gate.
|
||||
*
|
||||
* ★ Why one call and not two: the symbol and vector routes are fused into ONE
|
||||
* scoring pass so that a block the symbol path missed can still be admitted by
|
||||
* its vector score (V5) and so that the candidate pool is never truncated before
|
||||
* fusion (V7). Calling `recallBlocks` twice and merging afterwards cannot
|
||||
* reproduce either property.
|
||||
*
|
||||
* Degradation is reported on the returned array (`arr.degraded`), never by
|
||||
* throwing — the recall main path must survive a dead embedding service
|
||||
@ -673,12 +695,146 @@ export async function recallBlocksHybrid(store, embedder, query, options = {}) {
|
||||
new Error(`embedder returned dim ${queryVector.length}, declared ${info.dimension}`))
|
||||
}
|
||||
|
||||
// Group V wires `vectorScores` into scoring. Keeping the call here (and the
|
||||
// scoring seam closed) means group A ships a verified no-op on the hot path.
|
||||
// Group A only computes the vector; it is not in scoring yet. Reporting
|
||||
// 'hybrid' here would be a lie until group V wires it in, and a misleading
|
||||
// label is worse than no label — rename it when V actually participates.
|
||||
return withDiagnosis(recallBlocks(store, query, scoped), 'vector_ready_unused')
|
||||
// ── the wiring ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// This is the line P1 exists for. Until now the query vector was computed,
|
||||
// dimension-checked, and then DISCARDED: the call below passed `scoped`, which
|
||||
// carries `topK` / `signal` / `fingerprint` / `dimension` and nothing else. So
|
||||
// every V1–V7 scoring branch in `recallBlocks` was reachable only from unit
|
||||
// tests that hand-built a `vectorScores` Map; production recall was pure
|
||||
// symbol/FTS.
|
||||
//
|
||||
// ★ BOTH options have to be passed, and the reason is structural rather than
|
||||
// stylistic:
|
||||
// · `vectorScores` alone reaches only the V1 exact-band tie adjustment
|
||||
// (`:438`), because the fused candidate assembly at `:233` is gated on
|
||||
// `Number.isFinite(minScore)`; without it the vector route can never
|
||||
// admit a block the symbol path missed, which is exactly what V5 needs.
|
||||
// · `minScore` alone does nothing at all, because `:299` additionally
|
||||
// requires `vectorScores` to BE a Map before it builds vector candidates.
|
||||
// The two are one seam split across three guards, so they travel together.
|
||||
//
|
||||
// ★ Scheme A (score the existing space) over scheme B (teach `recallBlocks` to
|
||||
// accept a query vector). B would change a signature that phase A's golden
|
||||
// fixture and nine call sites depend on being synchronous and vector-free, and
|
||||
// it would move cosine arithmetic into a function whose contract is "store +
|
||||
// query text". A leaves `recallBlocks` completely untouched: the seam it
|
||||
// already exposes is the seam we drive.
|
||||
//
|
||||
// Cost, measured on this box before choosing (2000 blocks x 512 dims, the
|
||||
// profile's default space width): narrow `SELECT id, vector` 21 ms + parse and
|
||||
// cosine 60 ms ≈ 81 ms per recall; at 8000 blocks ≈ 310 ms. That is affordable
|
||||
// because the scan is bounded by the space and width filters below and runs
|
||||
// once per TURN (pre-step), not per candidate. Two design choices keep it from
|
||||
// growing worse than linear:
|
||||
// · the space/width filters are pushed into SQL, so blocks from a retired
|
||||
// space or a wrong width are never decoded;
|
||||
// · a space holding no comparable vectors scores nothing at all, so a database
|
||||
// with no embedded blocks (every phase-A store) pays one indexed SELECT that
|
||||
// returns no rows and takes the symbol path untouched.
|
||||
// The remaining risk is a very large single space, which is a ceiling to
|
||||
// measure before raising `topK`/space size — not a reason to ship a vector path
|
||||
// that never runs. Note the fail-closed consequence (§0.2/F5): blocks whose
|
||||
// fingerprint is empty are NOT scored, by design.
|
||||
//
|
||||
// ★ This does not introduce a NEW order of cost. `recallBlocks` already walks
|
||||
// every block for symbol union when `minScore` is finite (the `fusedCandidates`
|
||||
// step, `allBlocks(-1)`), so the fused path was always O(total blocks); what is
|
||||
// added here is one narrow indexed read plus the cosine arithmetic, and the
|
||||
// decode is bounded to the active space and width instead of the whole table.
|
||||
const vectorScores = scoreVectorCandidates(store, queryVector, scoped)
|
||||
const vectorRoute = vectorScores.size > 0
|
||||
if (!vectorRoute) {
|
||||
// An empty map would still open the fused candidate assembly (minScore is
|
||||
// finite) and then admit symbol matches with a vector weight of 0 — the
|
||||
// `0.7*0 + 0.3*coverage` regression V2 forbids. Reporting the honest reason
|
||||
// and taking the untouched symbol path is both correct and cheaper.
|
||||
return withDiagnosis(recallBlocks(store, query, scoped), 'no_vector_space')
|
||||
}
|
||||
|
||||
// `minScore` is only defaulted when the caller did not decide: an explicit
|
||||
// `0` is a deliberate "no threshold" and must survive (it is how the V7 and
|
||||
// pool-truncation criteria disable the gate).
|
||||
const minScore = Number.isFinite(scoped.minScore) ? scoped.minScore : DEFAULT_MIN_SCORE
|
||||
|
||||
return withDiagnosis(
|
||||
recallBlocks(store, query, { ...scoped, vectorScores, minScore }),
|
||||
// 'hybrid' from here on is a statement of fact, not an aspiration: the vector
|
||||
// route contributed at least one scoreable candidate to the same call that
|
||||
// produced these hits. Group A shipped `vector_ready_unused` precisely so this
|
||||
// rename could not happen before the wiring existed.
|
||||
'hybrid',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cosine similarity of the query vector against every block in its space.
|
||||
*
|
||||
* Returns the `Map<id, score>` that `recallBlocks` consumes. An empty Map means
|
||||
* "this space has nothing to score", which the caller must treat as the symbol
|
||||
* path rather than as a vector route with score 0 everywhere.
|
||||
*
|
||||
* Vectors are stored as JSON text (`memory_blocks.vector`, per the DDL comment:
|
||||
* "vector is a JSON array, matching HA's memory_blocks.vector"). A row that fails
|
||||
* to parse is SKIPPED rather than thrown on: one corrupt payload must not take
|
||||
* the whole recall down (decision §0.4), and the migration path already records
|
||||
* corrupt widths as a negative `vector_dim` so they are filterable.
|
||||
*
|
||||
* ★ No centroid centering here. C4 (subtract the space mean from BOTH sides) is a
|
||||
* separate slice layered on top of this one; doing it inline now would mix two
|
||||
* concerns in one diff and make C4's mutation unciteable.
|
||||
*
|
||||
* ★ Not L2-normalised on the way out. Cosine already divides by both norms, and
|
||||
* `putBlock` does not promise unit vectors, so normalising would change nothing
|
||||
* except to hide a zero vector behind a NaN.
|
||||
*
|
||||
* @param {import('./store.js').BlockStore} store
|
||||
* @param {number[]} queryVector already dimension-checked by the caller
|
||||
* @param {{fingerprint?: string, dimension?: number}} options
|
||||
* @returns {Map<string, number>}
|
||||
*/
|
||||
function scoreVectorCandidates(store, queryVector, { fingerprint, dimension } = {}) {
|
||||
const scores = new Map()
|
||||
// Without a space identity there is nothing to compare against: the vectors in
|
||||
// the table may belong to several incompatible spaces (§0.1), and a cosine
|
||||
// across two of them is a number without a meaning.
|
||||
if (typeof fingerprint !== 'string' || fingerprint === '') return scores
|
||||
|
||||
const rows = store.vectorCandidates(fingerprint, dimension)
|
||||
if (rows.length === 0) return scores
|
||||
|
||||
let queryNorm = 0
|
||||
for (const value of queryVector) queryNorm += value * value
|
||||
queryNorm = Math.sqrt(queryNorm)
|
||||
if (!(queryNorm > 0)) return scores
|
||||
|
||||
for (const row of rows) {
|
||||
let vector
|
||||
try {
|
||||
vector = JSON.parse(String(row.vector ?? ''))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!Array.isArray(vector) || vector.length !== queryVector.length) continue
|
||||
|
||||
let dot = 0
|
||||
let norm = 0
|
||||
for (let index = 0; index < vector.length; index += 1) {
|
||||
const value = Number(vector[index])
|
||||
// A non-numeric element makes the whole score meaningless. Skip the row
|
||||
// instead of letting NaN propagate into the sort, where it would silently
|
||||
// randomise the order (NaN comparisons are always false).
|
||||
if (!Number.isFinite(value)) {
|
||||
dot = Number.NaN
|
||||
break
|
||||
}
|
||||
dot += value * queryVector[index]
|
||||
norm += value * value
|
||||
}
|
||||
if (!Number.isFinite(dot) || !(norm > 0)) continue
|
||||
scores.set(row.id, dot / (queryNorm * Math.sqrt(norm)))
|
||||
}
|
||||
return scores
|
||||
}
|
||||
|
||||
/**
|
||||
@ -746,6 +902,16 @@ export function degradationNotice(hits) {
|
||||
// may say "matches below"; nothing === false => the branch must point at them.
|
||||
const nothing = !Array.isArray(hits) || hits.length === 0
|
||||
switch (mode) {
|
||||
case 'hybrid':
|
||||
// NOT a degradation: this is the healthy, fully-wired path (symbol + vector
|
||||
// fused in one scoring pass). It carries no notice for the same reason `ok`
|
||||
// would not — there is nothing the model needs to know.
|
||||
//
|
||||
// ★ Kept as an explicit case rather than relying on the `default:` branch.
|
||||
// `default:` exists to make an UNKNOWN mode fail loudly-safe ("unknown, not
|
||||
// absent"), so routing the healthiest mode through it would print a false
|
||||
// degradation notice on every single turn that had a vector hit.
|
||||
return ''
|
||||
case 'no_embedder':
|
||||
// Permanent configuration state, not a per-turn failure. The constant index
|
||||
// (systemPrompt.context -> renderIndex) already tells the model that block
|
||||
@ -777,6 +943,14 @@ export function degradationNotice(hits) {
|
||||
// yet. Not something the model needs to hear about — but keeping the case
|
||||
// explicit means a future mode cannot silently fall through to ''.
|
||||
return ''
|
||||
case 'no_vector_space':
|
||||
// A space IS configured and the embedder answered, but no block in that
|
||||
// space carries a usable vector (a fresh space, or every row pre-dates the
|
||||
// embedder). Unlike `no_embedder` this is a transitional DATA state, and it
|
||||
// is the one an operator most often mistakes for "semantic recall is on".
|
||||
// Still silent for the model: the fallback it received is the correct one,
|
||||
// and `stablemem_stats` is where the operator is told why.
|
||||
return ''
|
||||
default:
|
||||
// A mode nobody anticipated. "Not found" must not read as "not recorded":
|
||||
// this is the ONLY path a future mode can take, so it is the worst possible
|
||||
|
||||
45
lib/store.js
45
lib/store.js
@ -1068,6 +1068,51 @@ export class BlockStore {
|
||||
.all(limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate pool for the vector route: every block comparable to one space.
|
||||
*
|
||||
* Three narrowings, each of which `recallBlocks` would otherwise have to repeat
|
||||
* (and would eventually repeat inconsistently — the exact half-coverage bug F2
|
||||
* exists to stop):
|
||||
*
|
||||
* · `vector != ''` a row with no vector cannot score; decoding it
|
||||
* would only produce a zero.
|
||||
* · `fingerprint = ?` same space. Comparing vectors across spaces is
|
||||
* meaningless, not merely inaccurate (§0.1/§0.2).
|
||||
* · `vector_dim = ?` same width, when the caller names one. A stale
|
||||
* row of the wrong width is a decode hazard, and
|
||||
* `vector_dim` is maintained by a trigger, so
|
||||
* this filter is exact and index-friendly.
|
||||
*
|
||||
* ★ `SELECT id, vector` and not `SELECT *`: the scoring pass needs the vector
|
||||
* and the id, nothing else. Measured on this box at 2000 blocks x 512 dims,
|
||||
* the narrow read is 21 ms against 70 ms for `allBlocks(-1)` — and the gap is
|
||||
* dominated by the text columns, which grow with note length while vectors do
|
||||
* not.
|
||||
*
|
||||
* ★ Why this lives in the store and not in `recall.js`: every other query in
|
||||
* this file is a prepared statement over one table; a recall module that
|
||||
* reached for `raw()` to hand-build SQL would put storage knowledge back into
|
||||
* the scoring layer.
|
||||
*
|
||||
* @param {string} fingerprint the vector space to score against
|
||||
* @param {number} [dimension] required width; omitted means "any width"
|
||||
* @returns {Array<{id: string, vector: string, vector_dim: number}>} raw JSON text
|
||||
*/
|
||||
vectorCandidates(fingerprint, dimension) {
|
||||
const space = String(fingerprint ?? '')
|
||||
if (space === '') return []
|
||||
const sql = Number.isInteger(dimension) && dimension > 0
|
||||
? `SELECT id, vector, vector_dim FROM memory_blocks
|
||||
WHERE fingerprint = ? AND vector != '' AND vector != 'null' AND vector_dim = ?`
|
||||
: `SELECT id, vector, vector_dim FROM memory_blocks
|
||||
WHERE fingerprint = ? AND vector != '' AND vector != 'null'`
|
||||
const statement = this.#handle().prepare(sql)
|
||||
return Number.isInteger(dimension) && dimension > 0
|
||||
? statement.all(space, dimension)
|
||||
: statement.all(space)
|
||||
}
|
||||
|
||||
/** Close the handle. Idempotent (checkpoint V5). */
|
||||
close() {
|
||||
if (this.#db === null) return
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
"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/memory-log.test.js test/loop.test.js test/loop-ports.test.js test/loop-system-e2e.test.js",
|
||||
"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": {
|
||||
|
||||
@ -222,14 +222,22 @@ test('A3 mutation: a wrapper that adds any suffix to `why` must break A3', async
|
||||
}
|
||||
})
|
||||
|
||||
// ── the SUCCESS path's label (group A: vector computed, not yet used) ──────
|
||||
// ── the SUCCESS path's label (P1 flips it: vectors now participate) ────────
|
||||
//
|
||||
// The earlier A3b only pinned the two failure labels. Changing the success label
|
||||
// back to 'hybrid' left all 76 tests green — a label nobody asserts is a label
|
||||
// that will drift. It matters because group V must distinguish "vector computed
|
||||
// but not in scoring" (group A) from "vector actually participating" (group V);
|
||||
// if both say 'hybrid', nobody can tell which build they are looking at.
|
||||
test('A3c: a healthy embedder reports "vector_ready_unused", NOT "hybrid"', async () => {
|
||||
// A3b only pinned the two failure labels. Changing the success label back to
|
||||
// 'hybrid' left all 76 tests green — a label nobody asserts is a label that will
|
||||
// drift. The two success labels answer DIFFERENT questions and both still matter:
|
||||
//
|
||||
// `no_vector_space` the space exists but holds no comparable vector (a
|
||||
// fresh space). Says "nothing was scored" without
|
||||
// claiming a provider failure.
|
||||
// `hybrid` P1: the vector route really contributed scores to the
|
||||
// same call that produced these hits.
|
||||
//
|
||||
// A build that reports `hybrid` while never passing `vectorScores` is EXACTLY the
|
||||
// defect P1 exists for, so the assertion below (with a populated space) is the
|
||||
// one that must never drift.
|
||||
test('A3c: a healthy embedder over an EMPTY space reports "no_vector_space"', async () => {
|
||||
const store = seededStore()
|
||||
try {
|
||||
let embedCalls = 0
|
||||
@ -245,11 +253,11 @@ test('A3c: a healthy embedder reports "vector_ready_unused", NOT "hybrid"', asyn
|
||||
assert.equal(embedCalls, 1, 'the embedder must actually be called')
|
||||
assert.equal(
|
||||
hits.degraded,
|
||||
'vector_ready_unused',
|
||||
'group A computes the vector but does not score with it — the label must say that, ' +
|
||||
'because group V will need a different label once vectors really participate',
|
||||
'no_vector_space',
|
||||
'the fixture corpus carries no vectors in `fp-healthy`, so nothing can be ' +
|
||||
'scored: the honest label says so instead of pretending a vector participated',
|
||||
)
|
||||
assert.notEqual(hits.degraded, 'hybrid', "calling it 'hybrid' would be a lie until group V lands")
|
||||
assert.notEqual(hits.degraded, 'hybrid', "'hybrid' without a scored candidate would be a lie")
|
||||
// And the result is still the pure-symbol ordering.
|
||||
assert.equal(hits[0].text.includes('4324'), true)
|
||||
} finally {
|
||||
@ -257,18 +265,64 @@ test('A3c: a healthy embedder reports "vector_ready_unused", NOT "hybrid"', asyn
|
||||
}
|
||||
})
|
||||
|
||||
test('A3c mutation: labelling the success path "hybrid" must fail', () => {
|
||||
// Mirrors the exact one-line edit that used to slip through: with no assertion
|
||||
// on this value, reverting the rename was invisible.
|
||||
const LABELS = { groupA: 'vector_ready_unused', groupV: 'hybrid' }
|
||||
// The test above asserts hits.degraded === LABELS.groupA. Prove that swapping
|
||||
// the constant would break it:
|
||||
assert.notEqual(LABELS.groupA, LABELS.groupV)
|
||||
const simulatedDeployed = LABELS.groupV // what the mutated lib returns
|
||||
assert.throws(
|
||||
() => assert.equal(simulatedDeployed, LABELS.groupA),
|
||||
(err) => err.code === 'ERR_ASSERTION',
|
||||
'if lib returns "hybrid", the A3c assertion must throw',
|
||||
test('A3d: once the space holds vectors, the same recall reports "hybrid"', async () => {
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
try {
|
||||
const fingerprint = 'fp-healthy'
|
||||
store.putBlock({ text: '值班室分机号 4324', vector: [0.1, 0.2, 0.3], fingerprint })
|
||||
const healthy = {
|
||||
async embedText() { return [0.1, 0.2, 0.3] },
|
||||
info: () => ({ fingerprint, dimension: 3 }),
|
||||
}
|
||||
const hits = await recallBlocksHybrid(store, healthy, '4324', { topK: 1 })
|
||||
assert.equal(
|
||||
hits.degraded,
|
||||
'hybrid',
|
||||
'a scored vector candidate is participating — reporting the group-A label here ' +
|
||||
'would hide exactly the regression P1 pins',
|
||||
)
|
||||
assert.equal(hits.length, 1)
|
||||
assert.ok(hits[0].vectorHit > 0, 'and the hit really carries a vector contribution')
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('A3c†: A3d really bites — drop the scoring pass and "hybrid" becomes unreachable', async (t) => {
|
||||
// ★ A real mutation of lib/, not a re-assertion of local constants. The earlier
|
||||
// version of this test compared two string literals it had just defined, which
|
||||
// can never fail for a reason that matters (the same defect the mutation
|
||||
// discipline note calls out: a guard that cannot fail is not a guard).
|
||||
//
|
||||
// The mutation empties the candidate scan. If A3d still reported `hybrid`, the
|
||||
// label would be decoration rather than evidence — which is exactly the bug P1
|
||||
// fixes one layer up.
|
||||
await v5Mutant(
|
||||
` const rows = store.vectorCandidates(fingerprint, dimension)`,
|
||||
` const rows = [] // MUTATED: scoring pass produces no candidates`,
|
||||
async (mod) => {
|
||||
const store = new BlockStore({ path: ':memory:' }).open()
|
||||
try {
|
||||
const fingerprint = 'fp-healthy'
|
||||
store.putBlock({ text: '值班室分机号 4324', vector: [0.1, 0.2, 0.3], fingerprint })
|
||||
const healthy = {
|
||||
async embedText() { return [0.1, 0.2, 0.3] },
|
||||
info: () => ({ fingerprint, dimension: 3 }),
|
||||
}
|
||||
const hits = await mod.recallBlocksHybrid(store, healthy, '4324', { topK: 1 })
|
||||
assert.throws(
|
||||
() => assert.equal(hits.degraded, 'hybrid'),
|
||||
(err) => {
|
||||
assert.equal(err.code, 'ERR_ASSERTION', 'only the criterion may bite')
|
||||
t.diagnostic(`A3c† RAW RED >>> ${String(err.message).split('\n')[0]}`)
|
||||
return true
|
||||
},
|
||||
'A3d must go red when nothing is scored',
|
||||
)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@ -2913,13 +2913,15 @@ test('D3b: a clean recall overwrites last_recall with its own outcome', async ()
|
||||
const result = await ctx.tools.execute({
|
||||
callId: 'lr2', name: 'stablemem_stats', arguments: {}, signal: new AbortController().signal,
|
||||
})
|
||||
// ★ A healthy recall does NOT report 'ok': group A deliberately labels the
|
||||
// success path `vector_ready_unused` because the vector is computed but not
|
||||
// yet scored. Asserting 'ok' here was my mistake — the label that matters is
|
||||
// "no failure", and `embed_error === null` is the honest way to say it.
|
||||
// ★ A healthy WIRED recall reports `hybrid` (P1), not `ok` and no longer
|
||||
// `vector_ready_unused`: the block carries a vector in the active space, so
|
||||
// the fused route really contributed. The assertion that matters is "no
|
||||
// failure", and `embed_error === null` is the honest way to say it; the mode
|
||||
// value is asserted too so the label cannot silently drift back to the
|
||||
// group-A stage name while the wiring is in place.
|
||||
assert.notEqual(result.value.last_recall.mode, 'embed_error', 'no failure is recorded')
|
||||
assert.equal(result.value.last_recall.embed_error, null, 'and no error reason either')
|
||||
assert.equal(result.value.last_recall.mode, 'vector_ready_unused', 'the group-A success label')
|
||||
assert.equal(result.value.last_recall.mode, 'hybrid', 'the wired success label')
|
||||
} finally {
|
||||
await done()
|
||||
}
|
||||
|
||||
801
test/phase-b-p1-vector-wiring.test.js
Normal file
801
test/phase-b-p1-vector-wiring.test.js
Normal file
@ -0,0 +1,801 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* ## 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.
|
||||
*
|
||||
* ## Skip conditions
|
||||
*
|
||||
* The harness needs `dsh` and the two `dsh-base` peers resolvable from this
|
||||
* package. When they are not installed there is no headless process to drive, and
|
||||
* the test skips LOUDLY rather than asserting against a half-mounted app — the
|
||||
* same rule `loop-system-e2e.test.js` follows.
|
||||
*
|
||||
* @module dsh-memory/test/phase-b-p1-vector-wiring
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { createReadStream, existsSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { createServer } from 'node:http'
|
||||
import { createServer as createNetServer } from 'node:net'
|
||||
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()) {
|
||||
t.skip('no `dsh` binary on PATH — the headless harness cannot be driven here')
|
||||
return
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user