main
80 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| f3ee2d1cb0 |
test(memory): pin that the WRITE path marks stale, not the test (C4 follow-up)
C4j asserted that a stale-marked centroid falls back to uncorrected vectors — but it
called `store.markCentroidStale(fp)` **itself** to set up the state. That proves the
recall side honours the flag; it says nothing about whether production ever sets it.
Measured: deleting the one line in `#ensureCentroid` that persists the verdict
if (status?.stale === true) this.store.markCentroidStale(fingerprint)
left **all 306 criteria green**. The wiring had no guard at all.
This is the fifth instance of one shape in this branch:
W8 saveSpaceCentroid defined, zero production callers
C4d centroid built by the test's own rebuildCentroid call
P1 vectorScores hand-built in the fixture
D2 counters computed, never printed
C4j stale flag set by the criterion, not by the write path
A hand-fed input cannot prove a production path. So C4k drives only the production
entry points — `apply()` + `commit()` — and marks nothing itself:
after a settled rebuild stats.stale === undefined
after 3 more commits stats.stale === 1 (drift 3/8 = 37.5% > 20%)
Mutation — deleting that line in `#ensureCentroid`:
not ok 12 - C4k: the write path itself marks a drifted centroid stale
error: C4k: the WRITE path must persist the stale verdict ... undefined !== 1
## Why the drift is generated rather than forced
3 commits on top of a settled 8 puts the delta at 37.5%, past the 20% ratio — the
same path an ordinary growing space takes. Forcing `markCentroidStale` directly is
exactly what C4j does and exactly what this criterion exists to complement.
Gates: npm test 307/307 (0 skipped), npm run check 12/12, frozen faces untouched.
v0.1.0
|
|||
| 7652184832 |
fix(memory): honour the C4 order — a stale round recalls with UNCORRECTED vectors
The C4 slice instruction said, verbatim: "判 stale 的那一轮仍返回结果(用未校正向量)".
It was not implemented. `grep stale lib/recall.js` returned one comment and no code,
and `loadCentroid` carries no staleness verdict — so recall centred with whatever
mean it found, however old.
## Why not simply ask on the recall side
The obvious fix — call `centroidStatus` in `recallBlocks` — was built and measured:
# tests 305 # pass 304 # fail 1
not ok 97 - C3-k: the recall hot path must not run a COUNT query
error: 'lib/recall.js must not call centroidStatus — it runs countVectored
internally, and the recall path may only read the centroid'
C3-k is a rule this project set deliberately. A fix that breaks it is not a fix,
and the criterion caught the attempt — which is what it is for.
## What this does instead
The WRITE path already pays for the judgement on every commit (`#ensureCentroid`
calls `centroidStatus`). So it persists the verdict:
· `BlockStore#markCentroidStale(fp)` — `json_set(stats, '$.stale', 1)` when the
space is judged stale
· `#ensureCentroid` marks on `status.stale === true`
· `recallBlocks` honours it: `!centroid.stats?.stale` before centring
· cleared implicitly — `rebuildCentroid` → `saveCentroid` upserts stats without
the key
Recall never runs a COUNT; C3-k stays green.
## The bug this shipped with for one revision — and why it matters
The guard was first written `centroid.stats?.stale !== true`. The flag is stored as
the JSON number `1`, and in JS **`1 !== true` is true** — a strict comparison across
types — so the guard was a permanent no-op. Stale centroids kept being used.
**The entire suite was 305/305 green with that bug in place.** Nothing covered the
path. It was only visible on a fixture where centring is observable (the C4
hub/target space):
fresh centroid top1=target 0.9000 / 0.2129 / 0.1814
marked stale top1=hub 0.9928 / 0.9859 / 0.9803 <- raw cosine, uncorrected
after rebuild top1=target 0.9000 / 0.2129 / 0.1814
Hence C4j (below) rather than a comment claiming it works.
## Criterion C4j
Three assertions on that fixture: fresh → target; marked stale → hub AND the score
is the raw cosine (0.9928); rebuild → target again. Mutation — deleting the guard:
not ok 11 - C4j: a centroid marked stale falls back to uncorrected vectors
error: ... 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.
'target' !== 'hub'
## What this does NOT establish
That falling back improves ranking quality. An attempt to measure it could not
separate the cases: with a near-duplicate late corpus, scores saturate near 0.99
whether the mean is stale (0.997) or absent (0.994); only a freshly rebuilt mean
spread them (0.978..0.970). So this is a safety choice — a mean built from a
fraction of the space describes a space that no longer exists, and applying it is
unjustifiable on principle. The measurable win belongs to keeping the centroid
FRESH (the throttle in C3), not to skipping it.
Gates: npm test 306/306 (0 skipped), npm run check 12/12, frozen faces untouched.
|
|||
| 59e6a71f28 |
perf(memory): stop re-reading 79 MB of vectors to answer a one-bit question
`recallBlocks` unions symbol matches with vector candidates, and to do that it walks
the whole table. It used `allBlocks(-1)` — `SELECT *` — while the only thing it
needs from the vector column is whether one EXISTS.
Measured at 8000 blocks x 512 dims, per recall turn:
allBlocks(-1) SELECT * 242.8 ms
this read, narrow 22.2 ms
------------------------------------------
whole recallBlocksHybrid 624.0 -> 409.6 ms (-34%)
At 2000 blocks: 157.6 -> 106.0 ms (-33%). The saving is not marginal and it is on
the per-turn pre-step path, which runs on every message.
## What changed
`BlockStore#symbolCandidates()` returns the six columns the pool actually reads
(id, text_content, source, fingerprint, vector_dim) plus `(vector != '') AS
has_vector`. `recallBlocks` iterates that instead of `allBlocks(-1)`, and the pool
entry takes `hasVector: block.has_vector === 1`.
`allBlocks` itself is untouched — the bounded symbol-only path still uses it with
`poolSize`, and tests use it as the wide reference.
## Why this needed a criterion rather than a green suite
A narrower read is a behaviour change waiting to happen, and the suite would not
notice most of them: 304 criteria were already green before it. So the criterion
asserts the equivalence directly:
· same row set in the same order (the pool is a Map; insertion order is iterated)
· `has_vector` matches the predicate it replaced — `String(vector ?? '') !== ''` —
block by block, on a corpus containing a real vector, no vector, and an empty
payload
Mutation — `1 AS has_vector`:
not ok 7 - narrow read: symbolCandidates() sees exactly what allBlocks(-1) saw
error: has_vector must reproduce the predicate it replaced, for block blk_01db9be26e52
1 !== 0
## One divergence deliberately left alone
The literal string `'null'` counts as "has a vector" under the old predicate, while
`vectorCandidates` excludes it. That inconsistency predates this commit and is
documented rather than silently fixed: changing it is a separate behaviour change
that needs its own criterion, not a footnote in a performance commit.
Gates: npm test 305/305 (0 skipped), npm run check 12/12, frozen faces untouched.
|
|||
| 73a0a6b8d0 |
test(memory): pin D1 — a corrupt vector cannot take the recall path down (D1)
D1 was the one item on the coverage list with genuinely no criterion. Measured by
mutation, not by grepping test names:
corrupt vector handling: skip -> throw
phase-b-h 137/137 green, phase-b-a 38/38 green, recall-accounting 5/5 green
-> 145 criteria, zero red
That is decision §0.4 unguarded: "the vector side failing must not take the main
path down". One bad row would cost the whole turn its memory, and nothing noticed.
## The same probe found three items that ARE covered
Recorded because the parent's coverage matrix derived "zero hits" from grepping
criteria IDs against test names, and three of those were false negatives — the
criteria exist, they are just named after their subject rather than their ID:
C6 centroid partitioned by fingerprint loadCentroid ignoring it -> 3 red
(ticket②, ticket②-strong, W8)
W1 exec.signal reaches embedText dropping it -> 2 red (W5, W9c)
F1 fingerprint from provider info() deriving locally -> 5 red
So the matrix under-reports as well as over-reports. Only mutation distinguishes
"labelled" from "enforced".
## What the criterion does
Writes a corrupt payload straight into the row and requires the recall to survive:
store.raw().prepare('UPDATE memory_blocks SET vector = ? WHERE id = ?')
.run('["a","b","c","d"]', broken.id) // right length, non-numeric elements
Two guards narrowed what "corrupt" can mean, both measured and documented in the
criterion:
· 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`, so a wrong-LENGTH payload is filtered in SQL
before scoring sees it.
An array of the right length with non-numeric elements clears both and still cannot
be scored — which is exactly what a provider that changed its encoding, or a
migration that stringified values, would leave behind.
Assertions: no throw, the healthy neighbour is still recalled, and the bad row is
counted in `skippedUnscoreable` (D1's other half is D2's rule: skipping silently is
the same defect).
Mutation — a non-numeric component throws instead of skipping:
not ok 6 - D1: a corrupt vector payload cannot take the recall path down
error: D1: a single corrupt vector payload threw out of the recall path
(M-D1: non-numeric vector component). Decision §0.4: ...
Gates: npm test 304/304 (0 skipped), npm run check 12/12.
|
|||
| 5cfc982f39 |
fix(memory): count blocks dropped BEFORE the candidate pool exists (D2, second hole)
|
|||
| 7ad76167c5 |
fix(memory): separate "not enough blocks yet" from "no common direction" (C5)
MIN_SAMPLE (added in
|
|||
| 5462934a84 |
test(memory): pin the reason at the TOOL surface, the fourth place it can vanish (C5)
C5's reason now has three layers of coverage:
1. store — six fixtures, one per cause (`centroidStatus`)
2. plugin — `stats().centroid_state`, read-only (no rebuild scheduled)
3. tool — `ctx.tools.execute('stablemem_stats')`
Layer 3 is the one that matters in dsh: the model does not call `mem.stats()`, it
calls the tool. And the tool's handler is a single `return mem.stats()`, so a
later refactor that cherry-picks fields would keep layers 1 and 2 green while the
only surface anyone actually reaches goes silent.
That is the fourth instance of one shape in this branch:
P1 queryVector computed, dimension-checked, then dropped
D2 drop counters computed, then not printed
C5 the reason computed, read by nobody
C5 the reason surfaced on the service, but not pinned at the tool
Each time the value existed, the criteria were green, and no operator could see
it. So the criterion asserts on the tool's own envelope.
## Criterion
Drives the real ToolRuntime exactly as `test/phase-b-h.test.js` does, with a real
`AbortSignal` (`ToolRuntime` reads `callerSignal.aborted`, so `undefined` throws):
empty space -> centroid_state present, reason 'no_centroid'
after 8 commits -> reason 'ok', available true, sibling fields intact
drift past ratio -> reason 'stale', rebuildScheduled FALSE
The last assertion doubles as the read-only proof at this layer: a tool an
operator polls must not drive the rebuild loop.
Mutation — handler returns a cherry-picked subset:
not ok 10 - C5: the stats TOOL returns the reason, not just the service method
error: 'C5: the tool must expose centroid_state — that is the surface the model
and the operator actually call. ...'
## Two of my own assertions were wrong first
Recorded because both are the failure mode this branch keeps hunting:
- `assert.equal(s.centroid, s.centroid, ...)` — a tautology; can never fail.
- `assert.equal(typeof x !== 'undefined', 'boolean', ...)` — compares `true` to
the string `'boolean'`; fails for a reason unrelated to its subject.
Replaced with `assert.ok('last_recall' in healthy, ...)`, which states the actual
precondition. A criterion that cannot fail, or that fails for the wrong reason,
is worse than no criterion — it consumes attention and proves nothing.
Gates: npm test 301/301 (0 skipped), c5 10/10, npm run check 12/12.
|
|||
| 16cfdb75d6 |
docs(memory): say what centroidRecallState is NOT, and why it has no caller
The C4 slice reported this method as dead code: `grep -rn centroidRecallState lib/` returned nothing but the definition, and the instruction was "wire it into recall or delete it — do not leave it as furniture". Neither, and the reason is worth writing down rather than re-deriving: - It cannot be wired into recall. It calls `centroidStatus`, whose first line is `countVectored` — a COUNT that C3-k forbids on the per-turn pre-step path. The scoring path therefore uses `loadCentroid` (one indexed SELECT). - It should not be deleted. `assertC3RecallUnusable` in test/phase-b-h.test.js runs the SAME predicate against every C3 mutant, so this method is what pins Go's `LoadCentroid` semantics (`centroid_store.go:179-185`: a zero-sample centre is unusable, and the recall side carries no stale bit). So it is a diagnostic seam, like `StableMem#saveSpaceCentroid`. The name "Recall-facing projection" was the actual defect — it invites exactly the wiring C3-k forbids. The name stays (renaming would churn a mutation-shared predicate for no behavioural gain); the doc now says what it is and what it is not. Comment only, no behaviour change. Gates: npm test 300/300, npm run check 12/12. |
|||
| 44f883745f |
feat(memory): give C5's reason an outlet, with a read that cannot schedule (C5 follow-up)
|
|||
| 0bc160b1bd |
docs(memory): list the new drop counters in the log-format table (D2)
The JSDoc table above `logRecallTurn` documents which fields the degradation group contains. It still listed only `degraded` / `skipped` / `skippedDim` / `err` after |
|||
| bdd91234a5 |
test(memory): pin that a NON-ZERO drop counter survives to both surfaces (D2)
L2 asserts the three D2 counters are printed when every one of them is zero. That
proves the format string mentions them; it does not prove a real value makes it
through. Two things sit between the counter and the operator:
`segmentRecallBudget` slices the hits array — and `slice` does not copy
non-enumerable properties, which is what all the counters are;
`lastRecall` is assembled separately from the log line.
So "correct in the returned array" and "visible to an operator" are different
claims, and only the second one is what D2 is about.
## Fixture
Three blocks on separate axes, query mostly on axis 0, default `minScore`:
block0 cosine ~0.95 -> hit
block1 cosine ~0.30 -> skippedScore
block2 cosine 0 -> skippedNoEvidence
Measured through the real plugin path (apply + commit + contextForStep):
log : ... skipped=0 skippedDim=0 skippedScore=1 skippedNoEvidence=1 truncated=0 err=- ...
last: {"mode":"hybrid","skipped_score":1,"skipped_no_evidence":1,"truncated":0,...}
All three destinations are non-empty at once, so the criterion cannot pass by
accident of an empty fixture.
## Mutation
Removing the three fields from the log line turns both criteria red:
not ok 2 - L2: debugLog=true emits one line covering recall, budget, degradation and injection
not ok 4 - L2b: non-zero drop counters appear in the log and in last_recall
L2 alone would already catch that; L2b is what catches the other half — a counter
that survives the log formatting but dies in the slice, or lands in one surface
and not the other.
Gates: npm test 299/299 (0 skipped).
|
|||
| 891582041d |
fix(memory): print the downstream drop counters, not just compute them (D2 follow-up)
|
|||
| 07f8438964 |
fix(memory): count every dropped candidate, not just the two upstream ones (D2)
`recallBlocks` could make a candidate disappear in three downstream places, and
none of them incremented anything:
· MinScore gate it scored, but below the threshold
· structural gate neither a usable vector score nor a symbol hit
· caller topK survived everything, fell outside the requested count
Measured before this change, 6 blocks in one space, `minScore: 0.99`:
hits=0 skipped=0 skippedDim=0
That reading is indistinguishable from "the space holds one block". An operator
whose threshold is swallowing the whole corpus is told "nothing was excluded",
and goes looking at the wrong subsystem. Only the space filter and the width
filter were ever counted — the two that are decided before scoring.
## What this adds
Three non-enumerable counters, each named after its own remedy:
skippedScore lower the threshold
skippedNoEvidence re-embed: there was nothing to score
truncated not a defect — reported so the totals reconcile
Plus the invariant that makes the next silent path impossible:
**hits + skipped + skippedDim + skippedScore + skippedNoEvidence + truncated
=== the candidate pool.** A future `continue` that forgets a counter breaks it.
## Criterion
`test/recall-accounting.test.js`, one fixture per path, asserting each counter's
own value (not a merged total — "scored too low" and "nothing to score" have
opposite remedies). The fourth test is the reconciliation above.
Mutations, both in a scratch copy:
remove `gatedByScore += 1` -> criteria 1 and 4 red
remove `gatedNoEvidence += 1` -> criterion 2 red
## Collateral: two static mutation anchors moved
`test/phase-b-a.test.js` pins the V5 gate and the caller-topK truncation by
rewriting `lib/recall.js` text. Making the gate multi-line (to hold a counter)
and threading `drops` through `withSkipped` changed those exact strings, so the
anchors are updated to the new text — same mutations, same properties pinned.
Running the file confirms 38/38 afterwards; without the update three V5†
mutations failed on a stale anchor rather than on their subject.
Gates: npm test 298/298 (0 skipped), npm run check 12/12. Frozen faces untouched.
|
|||
| 68775d327f |
fix(packaging): ship lib/embed/, without which the published tarball cannot load
`package.json` declared
"files": ["lib/*.js", "lib/loop.js", "lib/loop/*.js", "lib/types/*.d.ts"]
`lib/*.js` matches ONE level. `lib/embed/http.js` therefore never entered the
tarball, while `lib/index.js:35` imports it:
import { createHttpEmbedder } from './embed/http.js'
## How this survived
Every test in this repo runs from the source tree, where the file obviously
exists. `npm pack --dry-run` printed "10 files" — a plausible number nobody had
reason to read through. The defect is only reachable from the one path that
ships: installing the tarball.
Measured, extracting the tarball and importing it:
IMPORT FAILED: ERR_MODULE_NOT_FOUND
Cannot find module '.../package/lib/embed/http.js'
imported from '.../package/lib/index.js'
After the fix, the same command:
IMPORT OK, exports: StableMem,apply,default,defaultPath,inject,instanceKey,lastUserText,name
## Fix
`lib/embed/*.js` added to the allowlist. The tarball goes from 10 to 11 files
(86.1 kB) and `lib/embed/http.js` is in it.
## Criterion
`test/packaging.test.js`:
1. every `.js` under `lib/` must be matched by at least one `files` pattern.
The patterns compile to regexes the way npm treats them (`*` does not cross
`/`), so the criterion needs no hand-maintained file list and cannot drift.
2. every relative import in `lib/index.js` must resolve on disk — otherwise
criterion 1 could pass vacuously for a file that does not exist.
Reverting `files` to the old list turns criterion 1 red:
not ok 1 - packaging: every file under lib/ is covered by package.json "files"
these files live under lib/ but no "files" pattern reaches them ...
lib/embed/http.js
## Why it belongs in this branch
V6 made the embedder assembly load-bearing (two instances, one per side). A
release that cannot import `lib/embed/http.js` has no embedder at all, so the
distinction V6 introduced would never reach a user. The bug predates this
branch; its blast radius is the shipped artifact.
Gates: npm test 294/294 (0 skipped), npm run check 12/12, npm pack 11 files.
|
|||
| c97775bd18 |
feat(memory): encode stored blocks on the document side, questions on the query side (V6)
The HTTP protocol carries a `side` field — `query` or `document`, HA's
`PurposeQuery`/`PurposeDocument` (`http_embedder.go:105` / `:109`). The port made
it a construction-time setting of the adapter (default `query`) and then **no
call site ever supplied it**: both the write path (`lib/index.js`) and the recall
path (`lib/recall.js`) took the default.
So every stored block was encoded as if it were a question. Nothing fails when
that happens — both sides produce unit vectors of the declared width — which is
exactly why it survived: the corruption is only visible in retrieval quality,
and retrieval quality was already the known-unmeasured part.
## Why two instances and not a per-call argument
`side` is a property of what you are encoding, so a per-call parameter is the
more natural shape. It is not the shape used here, for one reason: the adapter
travels `side` verbatim in the request body from a closure, and giving it a
second, per-call way to receive the same field would leave two sources of truth
for one wire value. Creating two adapters over the same endpoint/model/dimension
— identical fingerprint, differing only in `side` — keeps the wire contract as it
was and puts the distinction where it belongs: in assembly.
`lib/embed/http.js` is therefore **unchanged**.
An injected `config.embedder` (tests, a future local adapter) has no second
instance, so the write path falls back to it (`documentEmbedder ?? embedder`).
That is the previous behaviour, not a second code path — and it is pinned.
## Criterion
`test/phase-b-v6-sides.test.js` starts a real HTTP server that records the `side`
of every request, then drives the **production assembly** (`apply()` with
`config.embed`):
commit → request body side === 'document'
recall → request body side === 'query'
The assertion is on the field that actually went out on the wire, not on an
internal variable. A second test pins the injection fallback: a single injected
embedder must still serve both paths, or every existing test and every
single-sided adapter breaks.
Mutation M-V6 — pointing the write path back at `this.embedder`:
not ok 1 - V6: storing a block encodes it on the document side, asking encodes the query side
error: V6: a block being STORED must be encoded on the document side. ...
+ actual - expected
+ 'query'
- 'document'
## What this does NOT establish
That the provider actually encodes the two sides differently. `side` is a
request field; what the endpoint does with it is the endpoint's business, and
this repo has no model whose asymmetry is measurable. What is now true is that
the distinction reaches the wire instead of being silently defaulted away.
Gates: npm test 292/292 (0 skipped), npm run check 12/12. Frozen faces untouched.
|
|||
| 28866ac6e6 |
feat(memory): name why centring is disabled, instead of only reporting that it is (C5)
`centroidStatus` computed four distinct ways for centring to be unavailable and
then flattened all of them into `available: false`. The result was that
`no_centroid` and `dim_mismatch` both looked like `stale: null`, and `isotropic`
was indistinguishable from a healthy space except by `available`. That answers
"may I centre?" and says nothing about "why is semantic recall never
correcting?" — four different operator actions (build a centroid, fix the
dimension, wait for a rebuild, accept that this space has no common direction)
collapsed into one symptom.
Every branch now carries `reason`:
ok available: true
no_centroid no row for this space at all
dim_mismatch a centroid exists but its width is not this query's
zero_sample a row exists but was built from zero vectors
stale usable in principle, no longer describes the space (drift > 20%)
isotropic permanent property of the space: no common direction to remove
`zero_sample` is a fifth cause beyond the four the plan names, because the Go
branch has it too (`centroid_store.go:179-185`) and it is not the same problem
as a missing row.
## Criterion
`test/phase-b-c5-reasons.test.js` — one fixture per reason, asserting **the
reason value itself** (not `available`, which is multi-valued). Plus:
- an invariant test: `available === (reason === 'ok')`, so `reason` cannot drift
into decoration that disagrees with the conclusion it explains;
- a test that `centroidRecallState` still projects `stale: null` (the scorer must
not start reacting to staleness) *while* `reason` travels through, so an
operator can still see why.
Every call injects `schedule: () => {}`. `centroidStatus` schedules a rebuild
when it judges a space stale — a criterion that calls it without isolating that
side effect ends up triggering the very behaviour it is inspecting. C4e's first
version proved that the hard way.
## Mutation
Collapsing all six reasons into one value (`reason: 'unavailable'`) turns all
eight criteria red:
not ok 1 - C5: a space with blocks but no centroid row reports no_centroid
not ok 2 - C5: a centroid of the wrong width reports dim_mismatch
not ok 3 - C5: a centroid row with zero samples reports zero_sample
not ok 4 - C5: a centroid that drifted past the ratio reports stale
not ok 5 - C5: an isotropic space reports isotropic, not stale
not ok 6 - C5: a healthy space reports ok, and only ok carries available=true
not ok 7 - C5: reason and available never disagree
not ok 8 - C5: centroidRecallState keeps its stale:null projection but carries the reason
## What this does NOT change
Scoring, gating and the recall path are untouched: nothing reads `reason` to
decide whether to centre, and `centroidRecallState` keeps `stale: null`. This is
a reporting seam, not a behaviour change — C4's own criteria (C4a/b/c/d/e/g/h/i)
and phase-b-h's C3 assertions stay green.
Gates: npm test 290/290 (0 skipped), npm run check 12/12. Frozen faces untouched.
|
|||
| 5cb97edf39 |
fix(memory): stop scoring renormalised floating-point dust as a direction (C4 follow-up)
`centerVector` treated any positive norm as a direction:
norm = Math.sqrt(norm)
if (!(norm > 0)) return null
for (...) out[index] /= norm
A block that IS the space mean differs from it only by floating-point residue
(~1e-17). That residue passes `norm > 0`, gets normalised to unit length, and
then scores against the query as if it were signal.
## Measured
Space of two identical blocks, query deliberately off the block direction (if
the query equalled the mean, its centred form would collapse and centring would
fall back to raw scores, hiding the defect):
raw cosine, no centroid 0.8608
with centroid, before this fix 0.2183 <- dust, renormalised
with centroid, after this fix no hit <- dust judged "no direction"
At the default `minScore = 0.5` the 0.2183 scores were filtered anyway, so the
user-visible outcome (0 hits) does not change. What changes is that the number
is no longer manufactured: with `minScore: 0` the earlier build would surface
those blocks at a noise-derived score, and a future change to the threshold
would have made that reachable.
CENTER_EPSILON = 1e-9 is the floor. It also matches the Go side, where the
cosine of a zero vector is 0 rather than a random angle.
## Criterion C4i — and what it deliberately does not pin
C4i asserts the invariant: **every returned hit must carry the raw cosine
(0.8608); a value near 0.22 means dust was renormalised.** It therefore accepts
either of two future policies — drop such blocks (what this commit does), or
skip centring entirely for a space with no variance. The only thing it forbids
is continuing to score dust.
Reverting the guard (`norm > CENTER_EPSILON` -> `norm > 0`) makes C4i red, which
is the mutation this criterion exists for.
## KNOWN LIMITATION — reported upward, not fixed here
A space whose blocks are all identical now returns NO hits where it previously
returned them at 0.8608, because centring subtracts the only direction the space
has. That is not introduced by this commit — epsilon only made it deterministic —
but it is a real recall loss for a realistic case (the same note stored twice),
and it is a C4 regression against the pre-C4 behaviour.
Two candidates, deliberately NOT applied here because both change scoring
semantics and one departs from HA:
(a) keep dropping them (current, HA-consistent: Go's cosine of a zero vector
is 0, so those blocks fall below any threshold too);
(b) do not centre a space whose mean norm is ~1.0 — there is no variance to
remove — which preserves the 0.8608 raw scores. This adds an upper
threshold HA does not have, so it is the parent's call.
No criterion pins "0 hits" for the duplicate space: pinning it would encode the
limitation as intended behaviour.
Gates: npm test 282/282 (0 skipped), frozen faces untouched.
|
|||
| 363b1ba21a |
docs(memory): record what the C4 write path costs, and two "unwired" calls that aren't
Three comments that were either wrong or silent. No behaviour change.
## The first-build cost claim was false
`#ensureCentroid` said "首次建的代价很小:那时块还少" (the first build is cheap —
there are few blocks then). That is exactly backwards for the situation the code
exists to fix: a production database that already holds thousands of vectored
blocks while `graph_centroid` is empty. The W8 scenario IS a database with many
blocks and no centroid.
Measured on a real database (512 dims, 2000 vectored blocks, empty centroid
table, first commit through the production path):
FIRST commit after upgrade 105.1 ms (synchronous SELECT + parse + mean)
STEADY commits 8.7-9.4 ms (loadCentroid SELECT + centroidStatus COUNT)
and the first build grows linearly with the space (~420 ms at 8000 blocks). The
number is now in the comment, together with the decision it justifies: keep the
one-time stall rather than move the build to the async path, because an async
first build leaves the first recalls after an upgrade silently uncorrected —
precisely when n is large enough for centring to matter. A bounded stall is
easier to notice than a silent degradation.
The steady-state ~9 ms is new with C4 and is now stated as such: every commit
pays one `loadCentroid` SELECT plus the `countVectored` COUNT inside
`centroidStatus`. C3-k forbids that COUNT on the recall path, not on the write
path.
## Two calls with no production caller, and why neither is a dangling wire
`saveSpaceCentroid` and `#centroidRebuiltAt` both looked like the defects this
round kept finding (code written, never wired). They are not, but leaving that
unwritten meant the next reader had to re-derive it — and the two live cases
(`saveSpaceCentroid` in `lib/`, the throttle state on disk) really were dangling.
- `saveSpaceCentroid` is the public programmatic seam for a caller that computed
a centroid elsewhere. Production centroids are derived from the blocks by
`store.rebuildCentroid`, driven from `commit` via `#ensureCentroid`. Stated in
its JSDoc, along with the fact that the only in-repo caller is a test.
- `#centroidRebuiltAt` stays in memory, and the reason `graph_centroid.built_at`
cannot replace it is now written down: `built_at` records a BUILD, the throttle
needs the last SCHEDULE, and the two diverge precisely when a rebuild throws.
Throttling on `built_at` would re-schedule on every commit after a failed
rebuild — write amplification on the failure path. The cost of the in-memory
map (one extra bounded async rebuild after a restart, measured 85.5 ms at
2000x512) is smaller, and it is stated next to it.
Gates: npm test 281/281, npm run check 12/12. Frozen faces untouched.
|
|||
| febf9f6f3c |
test(memory): pin the C4 gates that were left unguarded (C4 follow-up, supervision)
Three of C4's control surfaces had no criterion at all: deleting them left every
suite green. This commit adds the criteria, and the two defects they exposed.
## What was unguarded, measured
Each mutation below was applied to a scratch copy of lib/ and run against the
suite as it stood at
|
|||
| 05e93b2f5c |
fix(memory): let the write path refresh a drifted centroid (C4 follow-up)
|
|||
| 0a6adff5cb |
fix(memory): wire centroid centering into both the write path and scoring (C4)
C4 的判据原文:「校正时查询向量与块向量都去均值,不能只处理一边」
(docs/phase-b-criteria.md:253,依据 HA centroid_store.go:443-451 的 centerBoth)。
C4 缺的不是一半,是两层都没接
1. 中心从来不存在。`saveSpaceCentroid`(lib/index.js) / `centroidStatus` /
`centroidRecallState` 在整个 lib/ 里没有生产调用者,`rebuildCentroid`
只有测试在调。所以生产库的 `graph_centroid` 永远是空表,`loadCentroid`
永远返回 undefined,`isAnomalous` 永远 false —— 校正一次也不会发生。
实测:`/tmp/p1/e2e.db` 的 `graph_centroid` 零行。
2. 打分不用中心。`lib/recall.js` 全文没有 centroid 一词,
`scoreVectorCandidates` 只算裸余弦。`centerBoth` 全仓零命中。
只做第 2 层就是又一个「代码写了没接线」:判据会绿、实现会绿、生产永远 false。
写入侧:lib/index.js
· 新增 `#ensureCentroid(fingerprint)`,在 commit 成功后调用
· 只在「本空间没有中心行」时建一次;有行就交给已有的 stale 判定
· 失败吞掉:一次重建失败不能让写入失败(同 D1:向量侧故障不得带走主路径)
· 不放在召回侧:rebuildCentroid 是 O(n·d)(实测 dim=512、2000 块 85.5ms),
而召回每轮 pre-step 都跑;C3-k 也明令 countVectored 不得进热路径,
而 centroidStatus 第一行就是它
打分侧:lib/recall.js
· `scoreVectorCandidates` 读一次中心(loadCentroid 纯读,一次索引查询)
· 仅当 `isAnomalous === true` 且 `centroid.dim === queryVector.length` 时启用
· 两侧同时减均值 + 重新归一化(新增 `centerVector`,对齐 HA CenterVector)
· 维度不符 ⇒ 不校正也不报错;减完是零向量 ⇒ 退回未校正路径
· 门控是正确性不是性能:各向同性空间无条件校正会毁掉结果
(实测 8 块正交 mean norm 0.3536:命中数 8 → 1,7 块余弦 +0.0496 → −0.1416,
且因 V5 结构性门(vectorScore <= 0 且 symbolHit === 0 直接丢弃)
minScore 调到 -1 也救不回来)
判据:test/phase-b-c4-centering.test.js(283 行,已登记进 npm test)
C4-0 前置:空间真的会被判为各向异性(mean norm 0.9729 >= 0.6)
C4a born-RED:去均值把 top-1 从 hub 翻到 target
红原文(28e18d0):hub=0.6949(v=0.9928) target=0.6901(v=0.9859)
绿后: target=0.6300(v=0.9000)
C4b mutation-GUARD:同值对余弦恒为 1(纯代数,不依赖语料)。
未实现时本来就绿(裸余弦同值对 = 1),只有「只减一边」能让它红。
这条不可省:实测只减 query 时 C4a 的 top-1 仍然是 target,C4a 抓不到。
C4c guard:没有中心时照常返回结果(hub 仍然赢),不是让召回失败
C4d 第一层的守门人:走真 apply() + commit() + recall(),不手工 rebuildCentroid,
断言写完 8 条后 loadCentroid 必须已存在,且端到端 top-1 是 target
—— 没有这条,前三条的绿只能证明算术,证明不了接线
变异自证(三档,全部在临时副本做,真改 lib/)
M1 去掉中心化(loadCentroid 恒 undefined) → C4a + C4d 红(5→3 pass)
M2 块侧不中心化(只减 query,即 M-C4) → C4a + C4b + C4d 红(5→2 pass)
C4b 红原文:got 0.18974704073471832 —— 只减一边的签名
M3 去掉写入侧 #ensureCentroid → 仅 C4d 红(5→4 pass)
C4d 红原文:after commits through the production path a centroid must exist
这一档证明 C4d 是唯一守住第一层的判据
一处必要适配(不是削弱)
test/phase-b-h.test.js 的 `ticket②-strong mutation` 原本依赖「commit 之后
本空间没有中心」来让「用错 key 查不到」成为可观测事实。C4 让 commit 自动
建中心之后,这个前提变了,它会因为「本来就有一个中心」而红 —— 红的原因
与它要守的约束无关。改法:commit 之后显式清掉本空间那一行,并加一条
前置断言。用例语义完全保留,只是把前提重新立起来。
★ What this does NOT establish
中心化改变的是向量间的夹角,因此能改变排序。它不能让未校准的绝对分数
变得可用:去均值之后余弦仍然是余弦,而 DEFAULT_MIN_SCORE = 0.5 从来不是
从中心化后的分布推出来的。阈值校准仍是
phase-b-p1-vector-wiring.test.js:51-58 记录的已知未解问题。
三门:npm test 277/277(0 skipped)、npm run check 12/12、npm pack 10 files 79.0 kB。
冻结面:docs/、README.md、scripts/、lib/loop* 零改动。
|
|||
| 28e18d0258 |
test(memory): bound what P1's green does and does not establish (P1 follow-up)
Two discipline holes left after |
|||
| 09be4de0eb |
test(memory): pin that ORDINARY_DEGRADATIONS decides the log LEVEL (P1 follow-up)
P1 registered two healthy modes in `ORDINARY_DEGRADATIONS` — `hybrid` (the wired
success path) and `no_vector_space` (a space with nothing comparable to score) —
and the commit message claimed that meant "a healthy wired recall does not log a
warning every turn". Nothing tested it. Measured on
|
|||
| 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
|
|||
| c1f7e06b22 |
test(memory): 补 377e02c 的判据假绿 —— multiCarrier 名不符实,真多块零覆盖
`multiCarrier(texts, convention)` 名叫"多块",实际 `content: [{ type: 'text',
text: joined }]` 恒为 1 个 block。所以 S2a/S2b 读起来是"多块路径已覆盖",
实际一条都没走多块分支:
M1 在 `const rendered = ...` 前插 `if (blocks.length > 1) return null`
(即 V2 那个错误实现)→ 22 条判据 pass 20 / fail 0,全绿。
这正是上一轮 375→377e02c 留下的假绿:不是缺测试,是 helper 名字把人骗了。
名字夸大覆盖范围比没有测试更毒,因为它读起来像有。
改法(只动 test,lib/loop.js 零改动,sha256 f28c5564… 前后一致):
- `multiCarrier` → `singleBlockCarrier`,实现不动,JSDoc 写明
"产出:1 个 text block 承载 N 个 section(lib/index.js:932 的真实形状)"
并记下它曾叫 multiCarrier 这段历史。
- 新增 `trueMultiCarrier(texts, kind)`:N 个 text block,每个 block 一个
section,rendered = texts.join('')(glued)。注释写明真多块没有 spaced 变体。
- S2a/S2b → singleBlockCarrier;S3 标题改成 "single content block carrying N
sections != those sections …" 并改用 singleBlockCarrier。
- S2c/S2d → trueMultiCarrier(['a','b']),断言不变。
变异自证(两条都咬住,且撤净后 sha256 回到基线):
M1 `if (blocks.length > 1) return null` → S2c 红("got 1 message(s)")
M2 删掉 glued 分支(只剩 `rendered !== spaced`)→ S2b 红("got 1")
对
|
|||
| 377e02c943 |
fix(memory): make the loop actually promote real recall into system
`promoteSnapshotSections` accepted only ONE carrier spelling: `content: []`.
The plugin never emits that shape. `lib/index.js:932` always attaches exactly
one `{ type: 'text', text: injected }` block whose text IS `sections[0].text`,
so `promotedSection`'s `if (blocks.length > 0) return null` matched on every
real recall and the promotion was a no-op in production. Recall stayed in
user/message history — the exact defect
|
|||
| 5c6aaf53cf |
feat(memory): land the loop ports without wiring them
The four files under lib/loop/ were written against 0.2.1-rc.2 and have been
verified to import on their own; they are the substrate a replacement loop
needs to take over step(). This commit only puts them in the tree — lib/loop.js
is untouched, so nothing imports them yet and behaviour is unchanged.
Two packaging facts they bring with them:
files `lib/*.js` does not recurse, so lib/loop/ was shipping nothing.
`lib/loop.js` and `lib/loop/*.js` are now listed separately because
neither glob covers the other.
peer runtime-context.js imports @deepseek-ai/dsh-session, which the peer
table never declared. G2b's allowlist covered it, so the static guard
passed over a real declaration gap.
test/loop-ports.test.js pins the port interface rather than its behaviour: the
modules import alone, the export names survive, and the inbox projection keeps
key "inbox" / stateVersion 1. That literal pair is a wire format shared with
stock-loop sessions — a different key silently reads as "no pending input", a
different version makes stateSchema.parse refuse stored rows. The schema's
parse() is also exercised against seven malformed persisted rows, since
replacing a zod schema with a hand-written check is exactly the kind of change
that can be a no-op and still look fine.
|
|||
| f1b74a35ef |
feat(memory): ship the profile overlay that mounts the replacement loop
|
|||
| 6d9ad62a8f |
feat(memory): surface recall as a system section instead of history
Per-turn recall currently enters through the `agent/pre-step` waterfall, and
that hook can only return `messages`. The recall block 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 — it runs before the
turn text is known, so it can only render input-independent text.
The loop's `preStep` is the first seam holding BOTH the assembled prompt and
this turn's claimed messages. `lib/loop.js` subclasses the harness AgentLoop and
wraps that seam on the agent instance: a pure snapshot carrier message is moved
out of `messages` and into `assembly.sections`, so it becomes part of the system
prompt for that turn and never enters history.
Deliberately decoupled from the memory plugin: nothing imports it or matches its
name. Promotion keys off the shape every snapshot-style injector already uses
(`source.sections`, the same shape `dsh-time-context` emits).
Constraints honoured, each of which was a measured failure elsewhere:
- The promoted section carries `interpolate: false`; recalled text is free text
and may contain `{{...}}`, which the strict interpolator rejects.
- The subclass defines no constructor and no static `inject`. The parent already
registers the turnBoundary + inbox projections, calls `setFactory(this)`, and
declares `{{provider}}`/`{{model}}`; re-registering any of them throws at
startup.
- Only the instance's `preStep` is patched, never the prototype, which other
agents share.
Also wires the new peer: `scripts/link-dsh.sh` gains dsh-agent/dsh-agent-loop
(without them the package cannot resolve at test time), package.json declares
dsh-agent-loop as a peer and adds the new test file, and G2b's allowlist admits
dsh-agent-loop as a first-party harness package, with the reason recorded.
npm test: 247 pass, 0 fail.
|
|||
| 783a7f87bb |
feat(memory): observable recall log behind config.debugLog
The plugin shipped with zero console.* and zero ctx.logger calls, so a
production recall failure left no trace: `degraded` is a return value the
caller sees once, and the injected text is only visible if the model quotes
it. Everything below is observability — no scoring, no DDL, no docs/README,
no new dependencies.
## The line
One summary per recall turn, covering the four fact groups asked for:
recall query=<n>ch hits=<n> topK=<n> zeroHit=yes|no
budget budget=<n> source=explicit|host|fallback kept=<n> singleHitOverBudget=yes|no
degrade degraded=<mode> skipped=<n> skippedDim=<n> err=<summary>
inject inject=<n>ch
`query` is LENGTH ONLY (code points, so CJK is not double-counted). The query
is the user's own message and a memory log is the last place it should be
copied; length beside `hits` is enough to tell "searched nothing" from
"searched the skill catalog".
`source` reads straight off segmentRecallBudget's own decision, and `kept` is
the post-slice count, so the line cannot disagree with what was injected.
`inject` is measured AFTER rendering, i.e. the exact string contextForStep
returns — not a re-derived estimate.
## Level policy (and why it is not always info)
· fault — degraded not in {ok,no_embedder,aborted,vector_ready_unused}
-> warn, emitted EVEN WITH debugLog OFF. A failed search is the per-turn
event where absence of evidence is not evidence of absence.
· zero injection -> debug (the commonest case on the shipping default must
cost nothing at normal verbosity, but still be one greppable line).
· otherwise -> info, only when debugLog is true.
`no_embedder` and `aborted` are deliberately NOT faults: the former is the
shipping configuration, the latter a deliberate stop. This is the same
judgement W11e already made for the model-facing notice — a constant warning
trains the reader to skip the channel.
## Logger source, and why ctx.logger alone was not enough
Source is ctx.logger('dsh-memory'); if the cordis build has no logger service
the code falls back to a console.warn/console.error shim (debug/info also go
to warn — stdout is never written, since `dsh --profile headless` prints the
ANSWER on stdout, so a line there is not noise but a corrupted answer).
★ Measured, and the reason this change installs its own sink: cordis ships
exactly ONE default exporter, an in-memory ring buffer
(cordis/lib/index.js:594-603), and no dsh bundle adds a terminal one. Calling
ctx.logger.info(...) in a real profile therefore writes to a buffer nobody
drains — the plugin would have looked instrumented while a headless run still
printed nothing. installStderrSink registers a scoped exporter on that same
service: it filters by message.name (the exporters map is process-wide, so
without the name check it would reprint every other plugin's warnings under
this prefix) and by severity, then renders with Logger.format and writes to
stderr. Disposal rides on ctx.effect inside LoggerService.exporter.
Two robustness details, both earned the hard way:
· the whole line travels as ONE `%s` argument — Logger.format expands
%s/%d/%o in the format string, so a provider error containing `%s` would
otherwise truncate the rest of the line (verified: "50% off %s" came out
"50% off undefined NaN");
· err is flattened to one line and cut at 80 chars — a log line must stay a
line;
· every logging path is wrapped, because a throwing logger would turn an
observability feature into a recall outage.
## Verification
test/memory-log.test.js (L1–L8b, 12 tests) pins: default is silent; the
summary carries all four fact groups in exactly ONE line; the query text
never appears; explicit/host/fallback source naming; a fault warns with
debugLog off; a zero-injection turn is debug not info; log-on equals log-off
for both the injected text and lastRecall; stdout is never written; the line
reaches stderr; foreign loggers are not reprinted. L4† is a real mutation —
it patches lib/index.js on a copy and asserts L4 goes red — because a guard
that cannot fail is not a guard (the W11d† lesson).
Self-tested in a real headless profile (dsh 0.2.1-rc.2, llmsproxy), same
question, only debugLog differing. Answer stayed "4471" on stdout in both:
debugLog=true dsh-memory: info: recall query=21ch hits=1 topK=3 zeroHit=no
| budget=838860 source=host kept=1 singleHitOverBudget=no
| degraded=no_embedder skipped=0 skippedDim=0 err=-
| inject=144ch
debugLog=false (no dsh-memory line; stderr carry only node's
ExperimentalWarning)
zero-hit turn dsh-memory: debug: recall ... zeroHit=yes ... inject=0ch
fault, OFF dsh-memory: warn: recall ... degraded=embed_error
err=fetch failed | inject=290ch <- speaks without opt-in
npm test: 237 tests, 237 pass, 0 fail (the W11d†ctx failure is gone — see
below). npm run check: 12/12 checkpoints.
## One test edit, forced by the caller change
W11d†ctx's mutation anchor was `const hits = await this.recall(query, { signal })`,
which no longer exists: contextForStep now rebinds as `const raw` because the
hits it renders come out of segmentRecallBudget (the preceding commit's work,
not this one). The anchor moves to the `raw` binding it would sit beside. The
mutation still flips W11d; only the anchor text changed.
|
|||
| ca0b5ff813 | fix(memory-b): keep non-enumerable recall diagnostics through budget slicing | |||
| 2069cd5f25 | feat(memory-b): budget-segment recall injection in the real host path | |||
| 771a053eea |
docs(CL-1F): restore A1 semantics and cite V7 logs for G1
A1: revert to the
|
|||
| 7fc3d46b9a | docs(CL-1): revert V2 documentation changes | |||
| 591f34070d | feat(memory-b): port V7 largeTopK candidate bound before fusion | |||
| 06155d4bcc | feat(memory-b): expose text document side as interface only | |||
| 8a87c973d2 | test(memory-b): add self-built tier-3 flip case | |||
| e2536f14f3 | feat(memory-b): port V5 candidate assembly and asymmetric MinScore | |||
| 5b217c1f78 | feat(memory-b): expose V3 vector and symbol attribution | |||
| b7eff5db66 | test(memory-b): strengthen C3 recall and corruption mutants | |||
| d73d47c081 | feat(memory-b): port C3 centroid staleness and async rebuild scheduling | |||
| d57c9dc798 |
feat(memory-b): port V2 non-exact fusion weights
Also clear three stale counts the release train authorised: criteria A1 wording (sync + hybrid), criteria G1 counts (173/12), README test-site count. |
|||
| fc92db9f5b | feat(memory-b): port V1 exact vector pin adjustment | |||
| 8bdd9f5ce0 | test(memory-b): make F8 mutants bite the positive criteria | |||
| 96adb172eb | test(memory-b): add F8 checkpoint for width validation | |||
| b5d3f6ec73 | feat(memory-b): add F7 fingerprint space stats | |||
| f62b826c64 |
test(memory-b): 补 unknownSpace / dimensionMismatch 的文案判据(四态齐)
你的复核成立:renderUnavailableTail 每态一个 clauses.push,但只有
spaceMismatch(D5b)与 noVector(D5c)有判据。删掉另两段,全套仍绿 ——
它们在渲染层是装饰,与恒 0 的 skipped_dim 同型。
我复现时先得到「各红 1 条」,查下去发现那是副本缺 README 导致
「README states no counts」那条报 ENOENT 的噪音,不是判据咬住。
去掉噪音后确认:你的结论正确,两态确实全绿。
补四条(每态一条判据 + 一条抑制变异)
D5e unknownSpace:断言「1 without a space label」,且断言文本里
**不含** browse / exact text —— 该态走语义路,借用 noVector 的
「可浏览」说法会误导(这是 F6 那条 browse≠recall 口径的第三次复用)
D5f dimensionMismatch:断言「1 whose width does not match this provider」
**且**指向「fix the config」这一补救,且不含 browse
D5e† 删 unknownSpace 的 clauses.push ⇒ 必须红
D5f† 删 dimensionMismatch 的 clauses.push ⇒ 必须红
真变异实测(删整段 clauses.push)
删 unknownSpace → not ok D5e、not ok D5e† (2 红)
删 dimensionMismatch → not ok D5f、not ok D5f†(2 红)
每条变异同时咬住「本体判据」与「自己的 suppression 测试」,不是只红一条。
两处写作教训
- D5f 的构造必须走 raw putBlock:F8 正确地拒绝显式传错宽度,
所以「库里有窄向量行」现实中只来自历史数据或修复脚本
- D5e†/D5f† 初版只 new Context() 没装 systemPrompt/tools 服务,
dispose 时 "Cannot read properties of undefined (reading 'context')"
—— apply() 的 inject 需要它们,已补
现状:四态 noVector / unknownSpace / spaceMismatch / dimensionMismatch
在**渲染层**各有判据与抑制变异,不再有装饰态。
验证
- npm test 166/166(162 + D5e/D5f/D5e†/D5f†)
- npm run check 10/10
- 干净副本实数见后续消息
|
|||
| 99d0670ebe |
fix(memory-b): 返工 ③④ —— trigger 直判据 + skipped_dim 不再是假字段
③ trigger 补直写判据(此前只有间接覆盖)
你的复核准确:摘掉 trigger 的 WHEN 会让 F3/D3 红,所以「间接有守卫」——
但没有一条盯 raw-writer。而 trigger 存在的全部理由就是防 raw-writer
(修复脚本、迁移、运维手敲 sqlite3)。putBlock 自己就能把列写对,
所以真实风险是「trigger 全没了也没人发现」。
MIG5 三条:
- raw UPDATE 把 vector 改成长度不符 ⇒ vector_dim 被拉回真值;清空则归 0
- raw INSERT 带错误 vector_dim ⇒ 被 trigger 覆盖为真值
- MIG5† 分别 DROP 两个 trigger,各自断言「谎言能存活」
真变异:从 DDL 里删掉整段 trigger ⇒ MIG5 前两条红(MIG5† 仍绿,因为它
主动 DROP,不依赖 DDL 是否存在 —— 这是两者分工正确的标志)。
④ skipped_dim 恒 0 是假字段,选 (a) 补维度过滤
你的诊断对,且比我的注释更狠:一个永远不能非零的字段,配合
`typeof === 'number'` 的断言,把 F4「维度失配与指纹失配分开计数」
做成了摆设。这与更早那处 assert.equal(skipped, 2) 是同一类。
实现:
- searchFts / 包含兜底都携带 dimension(来自 vector_dim 列)
- recallBlocks 新增 dimension 选项,在**空间过滤之后**做维度过滤
★ 顺序有意:先空间后维度,保证一块同时 stale 且窄时只计一次(记 space),
否则两个计数会重复计同一行、谁也加不出「被丢弃的块数」
- skippedDim 与 skipped 分别挂在返回数组上(F4 要分开)
- recallBlocksHybrid 把 space.dimension 注入 scoped,六个降级分支自动继承
- lastRecall.skipped_dim 改为读真实值
判据 F4b 四条:
- 同指纹不同宽度 ⇒ skippedDim=1 且 skipped=0(不串桶)
- 同时存在旧空间块与窄向量块 ⇒ skipped=1、skippedDim=1,不双计
- last_recall.skipped_dim 能非零
- F4b† 变异:删维度过滤 ⇒ skippedDim 归 0、窄向量被召回 ⇒ 红
实测:hits=[C 正常],skipped=1,skippedDim=1,两者都能非零。
一处测试构造的调整:窄向量块用 raw 写入而非 commit —— F8(正确地)
拒绝显式传错的宽度,所以「库里存在窄向量行」在现实中只能来自
历史数据或修复脚本,与 trigger 那条判据的假设一致。
文档
- criteria: F4 补「两个都能非零」与恒 0 字段的教训
- dev-notes 增第 6 类「恒为 0 / 恒为真的字段」,含自查问句
「这个字段的取值集合里,有没有一个能让我这条断言失败?」
验证
- npm test 162/162(155 + MIG5 三条 + F4b 四条)
- npm run check 10/10
- 干净副本实数见后续消息
|
|||
| d9280773aa |
fix(memory-b): 返工 —— last_recall 可查询 + renderIndex 按态分述
① embedFailure 有落点了(此前只有注释)
你的判断准确:degraded 是**单次调用的返回值**,调用方看得见但运营方查不到;
criteria:282 要的是「可查询」,两者不能互相顶替 —— 就像 states 不能顶替 skipped。
实现:
- StableMem.lastRecall:最近一次 recall 的 {mode, skipped_fp, skipped_dim,
embed_error, at},由 recall 路径写入
- stats() 增加 last_recall 字段(形状照 criteria:282)
- 健康召回记 'vector_ready_unused'(A 组语义),失败记 'embed_error'/'aborted'
- **不进四态**:那是从行派生的库状态,塞进单次事件会让同一时刻读两次
得到不同数字 —— 正是 D4 要避免的
判据 3 条:真 adapter 抛错后 stats().last_recall.mode 读得到该次降级;
干净召回会覆盖;D3b† 变异(删掉写入)⇒ 判据红。
② renderIndex 按态分述,不再一句包四态
你实测的四态揉成一句是真的错:旧句尾「still searchable by exact text」
对 noVector 成立、对 spaceMismatch 在生产召回路径上**不成立**
(带 fp 召回 0 条,不带 fp 浏览 1 条 —— 与 F6 裁定的 browse≠recall 同一条口径)。
改成分态子句:
spaceMismatch → 「from a retired space (reachable by browse only, not by recall)」
unknownSpace → 「without a space label (semantic recall unavailable)」
dimensionMismatch → 「width does not match this provider (fix the config, then re-embed)」
noVector → 「not yet embedded (still reachable by exact text)」
③ 无 embedder 走常态静默(P2,一并改)
无 embedder 是出货默认,不是异常。原来 2 块 noVector 每轮都被告知一次
「召回不了」,而与 exact recall 仍能命中相冲突,且与 W11e 的静默口径相反。
现在 space() === null 直接返回 ''(只留计数行);运营侧从 stablemem_stats 查。
测试用 reportNoVectorAlways 钩子单独覆盖该措辞,生产默认 false。
判据 4 条:D5b(mismatch 必须说 browse、不得说 still searchable)、
D5c(noVector 说 text recall,且先实证 exact recall 真能命中)、
D5d(无 embedder 时不重复告知)、D5b†(单句尾部 ⇒ 红)。
另把 D5/D5† 改为需要配置空间——它们原来在无 embedder 下断言,
与 D5d 的新口径直接冲突。
实测两场景
A 无 embedder:renderIndex 只报「2 durable note(s)」,无警告;states noVector=2
B 旧空间块:renderIndex 报「1 from a retired space (reachable by browse only,
not by recall)」;states spaceMismatch=1;生产召回 0 条 skipped=1
验证
- npm test 155/155(151 + 返工 4 条)
- npm run check 10/10
- 干净副本实数见后续消息
|
|||
| 160b5b5139 |
fix(memory-b): 三项验收整改 —— 去写死数字 / F8 硬报错 / blockStates 去 JSON.parse
一、skipped 断言不再写死数字
原 assert.equal(hits.skipped, 2) 属于「手抄数字」——与刚删掉的 README 计数同类:
fixture 一改就悄悄不再描述事实。
改为从构造库现算:expectedSkipped = counts.unknownSpace + counts.spaceMismatch,
并先断言 expectedSkipped > 0(防止变成「0 == 0」的假通过)。
二、F8 从「事后统计」改成「写侧硬报错」
原状:写侧维度校验只降级成「不嵌入」,坏向量仍能落库,靠 blockStates
的 dimensionMismatch 事后兜住 —— 那是 accounting,不是 validation,
与 criteria:165 的「硬报错」不符。
实现:putBlock 新增可选 expectedDimension,声明了宽度且不符 ⇒ 抛错不落库。
commit 在「调用方显式传 vector」时传 expectedDimension。
★ 两个入口必须分清,否则会与 W4 打架:
- 调用方 bug(显式传了宽度不对的向量)⇒ **必须抛**,否则它以
「块在、召回不了」的形式沉默很久
- provider 异常(embedding 返回宽度不对)⇒ 按 W4 降级为不嵌入,块仍写入
(把上游抖动抛给用户 = 让一次 provider 故障变成写入失败)
判据含三例:显式传错宽度抛;显式传对宽度通过;provider 返回错宽度降级不抛。
F8† 变异:把宽度检查改成恒不触发 ⇒ 坏向量落库 ⇒ 判据红。
三、blockStates 不再解析向量(性能账)
实测 2000 块 × 2048 维:
改前 354ms/次(其中 255ms 是纯 JSON.parse)
改后 4ms/次(约 90×)
而 renderIndex 每轮都调它 —— 这是挂在 pre-step 热路径上的固定成本。
做法:memory_blocks 新增 vector_dim 列(维度是行事实,不是负载内容),
- putBlock 写入时一并写
- 迁移时为存量行回填一次(只此一次,不在读路径)
- 加两个触发器保证 vector 与 vector_dim 永不脱节:
★ 理由:既然这一列的存在就是为了「不解析负载」,那每个写入者都背上了
保持一致的义务。漏一个写入者(修复脚本、测试 fixture、未来迁移)
就产生一行「维度是谎话」的数据 —— accounting 报它 active,
而召回静默跳过它。用触发器让这个状态**不可表示**,而不是只劝阻。
- 静态判据:blockStates 函数体内不得出现 JSON.parse(防以后「顺手简化」改回去)
四、embedFailure 记账
不进四态(它是单次调用事件,不是库状态),criteria 已补说明:
四态是库状态、从行派生;embedFailure 属于 recallBlocksHybrid 的 degraded,
由 W 组覆盖。塞进库状态会让「同一时刻读两次得到不同数字」,正是 D4 要避免的。
顺带修:F4† 的变异锚点因 blockStates 改写而失效(withMutatedCopy 显式报
「anchor not found」——这条守卫按设计响亮失败,没有静默变绿)。
验证
- npm test 148/148(143 + F8 四条 + 性能守卫一条)
- npm run check 10/10
- 性能实测见上
- 干净副本实数见后续消息
|