Commit Graph

49 Commits

Author SHA1 Message Date
5cfc982f39 fix(memory): count blocks dropped BEFORE the candidate pool exists (D2, second hole)
07f8438 gave the three gates a counter and claimed "every drop is countable". A
probe against the invariant found a fourth drop site, one stage earlier than any
gate:

    scoreVectorCandidates skips a row it cannot score
      · unparseable JSON
      · wrong width            (CENTER width vs query)
      · non-finite component
      · zero norm
      · centring collapsed it onto the space mean   (C4's dust guard)

Measured on three identical blocks with centring active:

    hits=0 skipped=0 skippedDim=0 skippedScore=0 skippedNoEvidence=0 truncated=0
    accounted = 0 of 3   ->  INVARIANT BROKEN

The whole space vanished while every counter read zero. Cause: a skipped row never
enters `vectorScores`, so `recallBlocks` never puts it in the candidate pool
(`if (!symbolMatch && !vectorSource) continue`), so no downstream counter can see
it. This is exactly the silent branch D2 forbids — deeper than where D2 first
looked, and I only found it because I ran the invariant against a centring case
instead of trusting the green suite.

## Fix

`skippedUnscoreable` threaded from `scoreVectorCandidates` (by reference, so the
early `no_vector_space` return can report it too) through `recallBlocks` into the
result, then into `last_recall` and the log line — the same three surfaces D2
already uses.

    hits=0 skippedUnscoreable=3 ...  accounted = 3 of 3  ->  INVARIANT HOLDS

## Criterion

`test/recall-accounting.test.js` gains case 5: three identical blocks, centring
engaged, asserting the count is 3 (not 0) and that hits + all seven counters
still equals the space.

The existing invariant case now sums seven counters instead of six — a shortfall
is how this hole would reappear.

Mutation — deleting the dust increment:

    not ok 5 - D2: a vector that centring collapses is counted, not silently lost
    error: D2: these blocks were dropped before the candidate pool existed, so no
           other counter can see them. ...

## Note on the label

These blocks are NOT counted as `skippedNoEvidence`: that would say "there was
nothing to score" when the truth may be "there was a vector and centring destroyed
it". Different remedies (re-embed vs. look at the centroid), so a separate
counter — the same reasoning that made C5 split its six reasons.

Gates: npm test 303/303 (0 skipped), npm run check 12/12.
2026-11-05 12:04:17 +08:00
7ad76167c5 fix(memory): separate "not enough blocks yet" from "no common direction" (C5)
MIN_SAMPLE (added in febf9f6) makes a one-vector space non-anisotropic: one
sample is not a direction. That verdict then fell straight into the `isotropic`
branch of `centroidStatus`, so a space holding a single block reported:

    reason = isotropic

Measured before this commit:

    1 block                reason=isotropic  available=false count=1 isAnomalous=false meanLength=1.0000
    2 blocks, orthogonal   reason=ok         available=true  count=2 isAnomalous=true  meanLength=0.7071

## Why the distinction is the whole point of C5

    isotropic        this space will never have a common direction — stop expecting centring
    too_few_samples  write a few more blocks and it starts working

Two diagnoses with opposite remedies. C5 exists so that an operator asking "why is
centring off?" gets an answer they can act on; reporting the first when the
second is true sends them to give up instead of to keep using the thing.

This is my own hole: I added MIN_SAMPLE, and the misclassification exists only
because of it. `zero_sample` (a Go branch) already covered count == 0; nothing
covered 0 < count < MIN_SAMPLE.

## Fix

A `too_few_samples` branch placed BEFORE the isotropic verdict — order is
load-bearing, because the isotropic check is what used to swallow this case.

    no_centroid → dim_mismatch → zero_sample → stale → too_few_samples → isotropic → ok

Stale still comes first: a centroid built from one block that has since seen
twelve is stale, and rebuild is the action, not "write more".

## Criterion

`test/phase-b-c5-reasons.test.js` gains case 11, asserting both sides of the
boundary: one vector reports `too_few_samples`, two vectors do not (MIN_SAMPLE is
2, so two samples must clear the branch).

Mutation — deleting the branch:

    not ok 11 - C5: a one-vector space reports too_few_samples, not isotropic
    error: C5: "not enough blocks yet" and "this space has no common direction" lead
           to opposite actions. ...   (actual: 'isotropic')

## Still HA-divergent, recorded not fixed

Two orthogonal blocks report `ok` / available with meanLength 0.7071 — i.e. the
0.6 threshold calls a two-sample space anisotropic and centring engages. That is
HA-consistent behaviour (HA has no minimum sample count), and it is now visible
rather than silent because the count and meanLength are in the reason's siblings.
Not changed here: raising the threshold would depart from the port.

Gates: npm test 302/302 (0 skipped), c5 11/11, npm run check 12/12.
2026-11-05 12:01:07 +08:00
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.
2026-11-05 11:55:44 +08:00
44f883745f feat(memory): give C5's reason an outlet, with a read that cannot schedule (C5 follow-up)
28866ac made `centroidStatus` name why centring is disabled. Nothing read it.
`stats()` reported `centroid` from `loadCentroid` — the raw row, which says WHAT
the centre is and not WHY centring is or is not happening — and the only two
callers of `centroidStatus` in `lib/` were `centroidRecallState` (which spreads
the result but is itself only used by tests) and `#ensureCentroid` on the write
path, which discards the return value. A grep for `.reason` under `lib/` returned
nothing but the definition itself.

Same failure as D2 one commit earlier: the value existed, was covered, and no
operator could see it.

## What this adds

`stats().centroid_state` — the verdict, alongside the existing raw `centroid`:

    centroid        the stored row (dim, mean, vectorCount, stats)
    centroid_state  { available, stale, reason, delta, rebuildScheduled, ... }
                    reason ∈ ok | no_centroid | dim_mismatch | zero_sample |
                             stale | isotropic

`centroid` keeps its exact previous shape, so nothing downstream changes.

## The part that is easy to get wrong

`centroidStatus` **registers a rebuild** when it judges a space stale. A stats
read must not mutate anything, so the call passes `schedule: () => {}` — with
the no-op scheduler `rebuildScheduled` is always false here, which is what a pure
read should report. Without it, every operator polling `stats()` on a drifted
space would keep the rebuild loop alive.

## Criterion

`test/phase-b-c5-reasons.test.js` gains a ninth case driving the real plugin:

    fresh space            -> reason 'no_centroid'
    after 8 commits + wait -> 'ok', available true, raw row and verdict both present
    drift past the ratio   -> 'stale', stale true, rebuildScheduled FALSE

The last assertion is the one that matters: it distinguishes "reports staleness"
from "acts on it".

Mutation — deleting the `centroid_state` field:

    not ok 9 - C5: stats() exposes the reason without scheduling a rebuild
    error: "Cannot read properties of undefined (reading 'reason')"

Also removed a tautology I had written in the same case
(`assert.equal(s.centroid, s.centroid, ...)`) — it can never fail, and a criterion
that cannot fail is worse than none.

Gates: npm test 300/300 (0 skipped), npm run check 12/12.
2026-11-05 11:54:12 +08:00
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 07f8438 and 8915820 added three counters to that same group — so the table
described a line the code no longer emits, and an operator reading the docs would
not know `skippedScore` was available.

Comment only; no behaviour change. `stats().last_recall` already carries the same
fields because it hands `this.lastRecall` through unchanged (lib/index.js:840),
which this change also makes explicit in the table's wording ("where the
candidates went" rather than "whether the answer is trustworthy" alone).

Gates: npm test 299/299, memory-log 17/17.
2026-11-05 11:51:59 +08:00
891582041d fix(memory): print the downstream drop counters, not just compute them (D2 follow-up)
07f8438 added `skippedScore` / `skippedNoEvidence` / `truncated` to the recall
result. They were correct and covered, and completely invisible: the debug log
line still printed only `skipped` / `skippedDim`, and `stats().last_recall`
carried only `skipped_fp` / `skipped_dim`.

A counter nobody can read is indistinguishable from no counter. The whole point
of D2 was that an operator whose threshold is swallowing the corpus reads
`skipped=0` and concludes nothing was excluded — that operator reads the LOG, not
the returned array.

## What this adds

- the debug line now ends its degradation group with
  `... skippedScore=<n> skippedNoEvidence=<n> truncated=<n> err=<...>`
- `last_recall` carries `skipped_score` / `skipped_no_evidence` / `truncated`,
  so the durable surface says the same thing the transient line does

Nothing else changes: the counters were already computed, and the existing
`skipped=0 skippedDim=0` substring assertions still match because the new fields
follow them.

## Criterion

L2 in `test/memory-log.test.js` already asserts the degradation group is printed
in one line; it now also requires
`skippedScore=0 skippedNoEvidence=0 truncated=0` to be present. That is the
"printed, not merely computed" half — the values themselves are pinned by
`test/recall-accounting.test.js` (non-zero cases).

Mutation: deleting the three fields from the log line turns L2 red —

    not ok 2 - L2: debugLog=true emits one line covering recall, budget, degradation and injection
    error: 'downstream drop counters'

Gates: npm test 298/298 (0 skipped), npm run check 12/12, memory-log 16/16.
2026-11-05 11:44:11 +08:00
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.
2026-11-05 11:42:06 +08:00
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.
2026-11-05 11:35:02 +08:00
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.
2026-11-05 11:32:49 +08:00
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.
2026-11-05 11:30:48 +08:00
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.
2026-11-05 11:23:32 +08:00
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 05e93b2.

1. The `isAnomalous` gate in `scoreVectorCandidates`
   `centroid?.isAnomalous === true &&` -> `centroid !== undefined &&`
   Result: c4 6/6 green, phase-b-h 137/137 green, phase-b-a 38/38 green.
   Why: every C4 fixture is anisotropic (two of them assert isAnomalous ===
   true), and C4c is "no centroid row", which is a different branch from "a
   centroid that was judged isotropic". The only isotropic assertion in the repo
   (phase-b-h:1133) checks `rebuildCentroid`'s verdict, not the scoring path.

2. `countVectored` on the recall hot path
   `store.countVectored(fingerprint)` inserted before the centroid read.
   Result: c4 6/6 green, phase-b-h + phase-b-a 175/175 green.
   Why: nothing scans lib/recall.js for the C3-k boundary. phase-b-a:67 does read
   recall.js, but it asserts the F2 fingerprint gate.

3. MIN_SAMPLE (this one is a defect, not just a missing criterion)
   A one-vector mean IS that vector, so its norm is exactly 1.0 and it clears
   0.6 by construction. Measured with a real clock and the real commit path on
   the C4 corpus: right after the 8th commit the centroid reported
   `vectorCount=1 meanLength=1.0000`, and a recall that returns 3 rows without a
   centroid returned 1 with it — the block that formed the "mean" was centred to
   the zero vector and dropped. The drift rebuild corrects it ~2s later, so the
   window is short but the state is wrong, and in a slowly growing space it lasts
   until the throttle expires.

## What this commit adds

- `BlockStore.MIN_SAMPLE` (= 2) and `vectors.length >= MIN_SAMPLE` in the
  anisotropy verdict. Two is the smallest number for which the mean is a property
  of the set rather than of one member.
- **C4h** pins both halves of that: the verdict (`isAnomalous === false` for one
  vector, with `meanLength === 1` asserted as the precondition that makes the
  0.6 test insufficient) and the consequence (the only block stays recallable).
- **C4g** pins the isotropic gate. Fixture numbers come from running the same
  probe against a gated and an ungated build, not from paper: 8 orthogonal
  one-hot vectors, `meanLength = 0.353553`, `isAnomalous === false`; a query
  along `unit(ones)` yields 8 hits of cosine 0.3536 with the gate, and 0 hits
  without it (every centred cosine turns negative and V5's structural gate drops
  the lot). A three-axis query additionally pins 0.8111 / 0.4867 / 0.3244.
  The identical-pair query is deliberately NOT the main assertion: with both
  sides centred its cosine is 1 either way, so it cannot discriminate.
- **C3-k** statically scans lib/recall.js for `countVectored(` / `centroidStatus(`
  after stripping comments, and carries a positive self-check: the same scan must
  find `centroidStatus(` in lib/index.js, so a broken regex cannot leave the two
  "must not contain" assertions vacuously true.
- **C4d** gained the assertion it was missing. It asserted that a centroid
  exists, is anomalous and has the right width — all true of a centroid built
  from a single block. It now also asserts `vectorCount === CORPUS.length`, and
  waits for the drift rebuild before asserting on contents. Without the count
  assertion C4d is green while the mean describes one block.

## Mutations verified (each red in a scratch copy, working tree untouched)

  delete isAnomalous gate        -> C4g red
  countVectored in recall.js     -> C3-k red
  drop MIN_SAMPLE                -> C4h red
  revert "refresh a drifted centroid" -> C4d + C4e red

## Documentation that had drifted from the code

- recall.js still said "No centroid centering here. C4 ... is a separate slice
  layered on top of this one" directly above the function that now implements
  centering. Replaced with a pointer to where it actually lives.
- The one-sided-centring cosine was recorded as 0.3787 in the comment and "near
  0.38" in C4b's message. Measured on C4b's own five-block fixture it is 0.1897
  (cosine is symmetric, so centring either single side gives the same value).
  Both now say 0.1897 and name the fixture.

## Measured costs (for the write path this slice touches)

Pre-populated space, 512 dims, 2000 vectored blocks, empty graph_centroid — the
state a production database is in when it upgrades:

    FIRST commit after upgrade   105.1 ms   (synchronous O(n*d) rebuild)
    STEADY commits               8.7-9.4 ms (loadCentroid SELECT + centroidStatus COUNT)

The first build is accepted as-is: it happens once per space lifetime, it is
bounded, and moving it to the async path would leave the first recalls after an
upgrade silently uncorrected — a bounded stall is easier to notice than a silent
degradation. The steady-state COUNT is new with C4; C3-k keeps it off the recall
path, where it would run every turn.

## Real path (dsh headless, overlay on the real profile)

Same corpus and query, only the centroid row differs:

    centroid present (count=8, meanLength=0.972934)
      [1] (text 0.63) 第七号仓库的备用门禁码是 QP47-812。
    centroid deleted
      [1] (text 0.69) 这段时间一切照旧,没有什么特别的事情。
      [2] (text 0.69) 第七号仓库的备用门禁码是 QP47-812。

top-1 moves from the hub to the target, which is the behaviour C4 exists for.

## What this does NOT establish

Centring changes angles; it does not calibrate absolute scores. DEFAULT_MIN_SCORE
= 0.5 was never derived from a centred distribution, and the uncalibrated-absolute-
score problem recorded in phase-b-p1-vector-wiring.test.js:51-58 remains open.
A green C4 means "centring is wired into scoring and changes the ranking in an
anisotropic space", never "semantic recall is good now".

C5 (distinguishing no_centroid / dim_mismatch / stale / isotropic reasons) is
untouched: `centroidRecallState` still returns `stale: null`.

Gates: npm test 281/281 (0 skipped), npm run check 12/12, npm pack 10 files.
Frozen faces untouched: docs/, README.md, scripts/, lib/loop*.
2026-11-05 11:21:02 +08:00
05e93b2f5c fix(memory): let the write path refresh a drifted centroid (C4 follow-up)
0a6adff 里我自己留了一个洞,而且正是这一整轮反复抓的同一个病:逻辑写了,但没被调到。

`#ensureCentroid` 当时的形状是「没有中心就建一个,有就 return」:

    if (this.store.loadCentroid(fingerprint) !== undefined) return
    this.store.rebuildCentroid(fingerprint)

于是中心永远停在第一次 commit 的快照上。`rebuildCentroid` 只算一次,
之后块数一直涨,均值却再不动 —— 块数翻八倍,它还描述着 1 条块。
召回侧只 `loadCentroid` 纯读,永远不会自己纠正。

根子在于 `centroidStatus`:它内部早写好了完整判据(块数漂移 > 20% ⇒ stale,
10 分钟节流 + 异步重建),而在 C4 之前它在 lib/ 里**没有任何生产调用者**。
我接线时只接了「建第一次」,让它继续当着死代码 —— 这跟 P1/C4 要修的
「代码写了没接线」是同一类错误,只是换了一层。

修法:有中心时不再 return,交给 `centroidStatus` 判。它自己决定要不要重建。
仍然放在 commit 之后而不是召回路径,正是因为它第一行是 `countVectored`,
C3-k 明令该查询不得进每轮 pre-step 的热路径。

判据 C4e:写入侧必须让漂移的中心重新跟上

    commit 1 条  → 中心 vectorCount = 1
    再写 7 条    → 漂移 700% >> 20%
    假时钟拨过 2s → 中心 vectorCount 必须 = 8

M4 变异(退回 0a6adff 的「有中心就 return」)红原文:

    C4e: a centroid that drifted past the 20% ratio must be rebuilt by the
    write path. It is still describing 1 blocks while 8 carry vectors
    —— nothing in lib/ ever consults the stale judgement, so the mean stays
    frozen on whatever the first commit happened to see.

★ C4e 第一版是假绿,一并记下来

第一版在断言前调了 `mem.store.centroidStatus(SPACE)` 来验 stale 前置。
但 `centroidStatus` 自己就会调度重建 —— 于是不管写入侧接没接,重建都会发生,
判据在自证。M4 变异下它 6/6 全绿,才把这个问题揪出来。
改成手工算漂移比(不碰 centroidStatus)之后,M4 才咬得住。

同一类陷阱会在任何「被测函数恰好也能触发被测行为」的地方复现,
所以判据里宁可自己算前置常量,也不去调那个会把副作用带进来的接口。

★ What this does NOT establish

C4e 证的是「漂移会被发现并触发重建」,不证重建的时机对用户体验无感 ——
10 分钟节流窗口内中心仍是旧的,这段时间召回用的还是上一个快照的均值。
窗口长度与漂移比的关系需要实测分布,属效果范畴,不在本片。

三门:npm test 278/278(0 skipped)、npm run check 12/12、npm pack 10 files 79.3 kB。
冻结面:docs/、README.md、scripts/、lib/loop* 零改动。
2026-11-05 10:47:43 +08:00
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* 零改动。
2026-11-05 10:41:38 +08:00
1309afec93 fix(memory): wire the computed query vector into recall scoring (P1)
`recallBlocksHybrid` computed `queryVector`, verified its length against the
declared space, and then threw it away:

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

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

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

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

Scheme A, and why not B

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

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

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

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

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

New diagnostic modes, both non-faults

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

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

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

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

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

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

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

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

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

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

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

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

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

Test updates forced by the wiring (not cosmetic)

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

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

旁证 (not acceptance): npm test 268/268, npm run check 12/12.
2026-11-05 00:07:29 +08:00
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 6d9ad62 claimed to fix.

6d9ad62's "surface recall as a system section instead of history" was never
true: the promotion it relies on was blocked by that content-non-empty check.
The unit tests could not see it because their `snapshotMessage()` helper built
`content: []` — a shape no producer emits — and the old P4 asserted "real
content blocks are NOT promoted", which is the opposite of reality.

It was confirmed today, from `lib/index.js:932`'s constant one-text-block
injection, and is fixed here.

The rule is now "move the text, don't rewrite it": a snapshot carrier with
content is promoted only when the rendered blocks equal the sections' text
under one of the two canonical joins (`''` glued, `'\n\n'` spaced), and the
promoted section text is the rendered content, so relocation is assertable.
Non-text blocks, mismatched text, and the stock runtime-context snapshot (whose
content carries a preamble the sections do not) pass through untouched.

Tests: S1-S7 plus real multi-block S2c/S2d pin the acceptance surface; every
negative case carries a positive control in the same decision, so the old
"content non-empty => reject" rule cannot satisfy it. P4 is split into P4a/b
(content == sections promotes; content != sections does not) with P4c proving
the split is decided by text, not by presence of content.
`test/loop-system-e2e.test.js` mounts the real HologramAgentLoop on real dsh
services, runs one turn, and asserts on the request the adapter received: the
recall is in the system prompt (E1) and in no non-system message body (E2),
carried by exactly one message (E2b).
2026-11-04 18:24:58 +08:00
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.
2026-11-04 17:27:36 +08:00
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.
2026-11-04 14:54:49 +08:00
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.
2026-11-04 08:15:23 +08:00
ca0b5ff813 fix(memory-b): keep non-enumerable recall diagnostics through budget slicing 2026-11-03 21:35:59 +08:00
2069cd5f25 feat(memory-b): budget-segment recall injection in the real host path 2026-11-03 21:31:36 +08:00
591f34070d feat(memory-b): port V7 largeTopK candidate bound before fusion 2026-11-01 17:37:48 +08:00
06155d4bcc feat(memory-b): expose text document side as interface only 2026-11-01 17:21:46 +08:00
e2536f14f3 feat(memory-b): port V5 candidate assembly and asymmetric MinScore 2026-11-01 15:50:17 +08:00
5b217c1f78 feat(memory-b): expose V3 vector and symbol attribution 2026-11-01 15:34:55 +08:00
d73d47c081 feat(memory-b): port C3 centroid staleness and async rebuild scheduling 2026-11-01 12:32:16 +08:00
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.
2026-11-01 12:01:22 +08:00
fc92db9f5b feat(memory-b): port V1 exact vector pin adjustment 2026-11-01 11:51:13 +08:00
b5d3f6ec73 feat(memory-b): add F7 fingerprint space stats 2026-11-01 10:50:46 +08:00
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
- 干净副本实数见后续消息
2026-10-30 17:07:19 +08:00
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
- 干净副本实数见后续消息
2026-10-30 16:49:55 +08:00
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
- 性能实测见上
- 干净副本实数见后续消息
2026-10-30 16:29:05 +08:00
b73607ed02 feat(memory-b): F3/F4 四态派生计数 + D3 工具面 + D5 提示渲染
顺序遵守裁定:F2 过滤先落地(上一条 425c441),这一条才加计数。
先做计数会数出一个恒为 0 的假指标。

四态定义与归属
  noVector           vector 为空                     回填
  unknownSpace       有 vector 但 fp 为空            重嵌(说不出来属空间)
  spaceMismatch      有 vector、有 fp ≠ active        重嵌到当前空间
  dimensionMismatch  有 vector 但宽度不对             改 provider 配置(F4:与上面分开)
  embedFailure       本次调用拿不到向量                ← 不在这里,它是单次事件不是库状态

F3/F4 判据(4 条行为 + 3 条变异)
  F3   四态从库派生;skipped 非零(实测 skipped=2,不是恒 0)
  F4   dim 与 fp 分开计数,且不互相重复计入
  F3   改库/删库后计数跟着变(证明不是内存累加,D4)
  F3   skipped 在符号路也计(fusion.go:495 同款:只测向量路等于没测)
  F3†  内存累加器实现 ⇒ 「跟随数据库」断言红
  F3†symbol 计数只挂在向量路 ⇒ 符号路的 skipped 断言红
  F4†  把 dim 并进 spaceMismatch ⇒ 分桶断言红

D4:从 DB 派生,不累加
store.blockStates(activeFp, activeDim) 每次扫行计算,无缓存无累加。
判据用「绕过插件直接 UPDATE / DELETE,计数必须跟着变」证明。

D3/D5:两个消费者,一个来源
- renderIndex() 与 stablemem_stats 都调 blockStates(),不各留一份
- D5 的提示读同一个 unavailable 计数(noVector+unknownSpace+
  spaceMismatch+dimensionMismatch),说明「N 条暂不可语义召回,
  仍可按精确文本检索」
- D3 判据含无空间/有空间两例:无 active space 时 mismatch 必然为 0
  (没有参照物),这条我第一版判据写错了,已按实际语义修正

★ 一处新工具引入的回归(已修)
新增 stablemem_stats 时把工具名硬编码,第二个实例挂载时撞名
"tool 'stablemem_stats' is already registered" —— 与之前 prompt context
名同型:isolate() 不分区工具名。已改为可从 config.statsToolName 覆写,
loader 测试同步传参。

★ 一处「注册了但跑不到」的坑(已修)
registerTools 里我用了 return ctx.tools.register(...),后续第二个
register 永远不可达,但文件读起来像注册了两个。已去掉 return 并注释。

验证
- npm test 143/143(138 + F3/F4 四条 + 变异三条 + D3/D5 两条)
- npm run check 10/10
- 实测数字(5 块构造库):
    blockStates('fp-cur',3) = {total:5, active:1, noVector:1,
      unknownSpace:1, spaceMismatch:1, dimensionMismatch:1}
    recall("缓存", fp-cur) → 返回 1 条, skipped=2
    recall("4324", fp-cur) → 返回 2 条, skipped=2
- 干净副本实数见后续消息
2026-10-30 15:59:22 +08:00
425c441470 feat(memory-b): F5 固化为 fail-closed 判据 + F2 符号路空间过滤
裁定 1(F5):fail-closed,写侧直接拒收
docs/phase-b-criteria.md §0.2 从「待拍板」改为「已定」:
  无名向量的块直接拒收,比放过再靠下游兜更能守住空间隔离;
  安全语义优先,**不为 HA 的疑似缺陷同构**
  (HA 的 block_recall.go:140 三个 && 让空 fp 被放行,这是要修正而非复刻的行为)。
落点分两层并写明「缺一层都不算落实」:
  写侧 putBlock 抛错(已实现)
  读侧存量行归 unknownSpace、不参与向量召回(F 组)

F5 判据(5 种输入形态 + 变异)
  省略 / 空串 / undefined / 数字 / null 一律抛错,且都不落库
  合法形态仍工作(无向量无 fp;有向量有 fp)
  F5† 变异:删掉写侧守卫 ⇒ 无名向量被接受 ⇒ F5 必红

顺带修一处:lib/store.js 的 vectorFP 加 String() 归一。
原先变异摘掉守卫后会以 SQLite bind 错误崩,掩盖了真正的断言失败
(变异因为错误的原因变红 = 不算证据)。

裁定 2(F2):先过滤,后计数
F2 —— 空间过滤必须覆盖符号路,不只向量路
- store.searchFts 现在返回 fingerprint / hasVector,
  两条 intake 路径(FTS 与包含兜底)都携带身份字段
  ★ 兜底路径第一版漏了这两个字段,导致过滤器看到 hasVector === undefined
    而放行 —— 正是「过滤器只覆盖两个入口中的一个」那种 bug
- recallBlocks 新增 fingerprint 选项,在 pool 构建后统一过滤
  刻意**不复刻** HA 的三连 &&:本仓 fail-closed,
  带向量但无法命名空间的块一律排除(§0.2 裁定)
  无向量的块不受影响(它们本来也不参与向量路,文本仍可检索)
- recallBlocksHybrid 在函数入口解析一次空间并注入 options,
  六个降级分支全部经 scoped 调用 —— 逐调用点设置会在加第七个分支时漏掉一条

F2 判据(4 条行为 + 2 条独立变异)
  F2   符号路命中旧空间块 → 被排除
  F2   不传 fingerprint → 过滤器不生效(phase A 行为不变)
  F2b  有向量但 fp 为空 → fail-closed 排除(HA 此处会放行)
  F2   hybrid 在所有降级路径上应用同一过滤
  F2†  删掉过滤器 → 旧空间块回来
  F2†b 让 searchFts 不携带身份 → 过滤器失明(过滤只剩一个入口)

A1b 的调整(必须说明)
A1b 原先逐字节锁死 recallBlocks 源码,而 F2 合法地需要改这个函数。
改为守它真正要守的东西:**不传 fingerprint 时行为不变**
  - 静态:过滤器必须 gated 在显式传入的 fingerprint 上
  - 行为:golden fixture 前 5 例逐字段一致
依据:golden 语料 12 个块全部没有 fingerprint,
所以 F2 对 phase A 是纯增量;A2 仍全绿即为证。

验证(干净副本:git archive → 空目录 → link-dsh → 自跑)
见下一条消息附实数。

未 amend / 未 force push。
2026-10-30 15:29:23 +08:00
d972a2d135 fix(memory-b): W11f —— aborted / default 零命中时不再承诺条目
接你备好的四个 patch(ready-lib / ready-test / README / docs),
应用前逐个审过内容,dry-run 干净,应用后 md5 与你给的期望值一致:
  lib/recall.js          c8fdffd25d5340291fadf6cb26e1741f
  test/phase-b-h.test.js c06685abb7672fa806ca37e0d1cb8947
  README.md              3c183b045dd7de4a54338ed9d01b3738

问题(我上一轮修 embed_error 时只修了那一个分支)
  aborted 与 default: 在零命中时仍说「matches below are …」,而下面没有条目。
  aborted + 零命中不是边角 —— 用户点停止、且这条查询本来就查不到东西,
  是取消最常见的形态。
  default: 更糟:它是未来任何未知模式**唯一**会走的路,
  最不该在这里开始编。

修法(补丁采用)
  把「有没有条目」只问一次,四个分支共用:
    const nothing = !Array.isArray(hits) || hits.length === 0
  default 用「nothing was found, so treat this as "unknown", not "absent"」——
  把「没搜到 ≠ 没有」这条口径落在**兜底分支**上,
  而不是每加一个模式才记得补一次。

W11f 判据:表驱动、双向
  遍历 [undefined, no_embedder, embed_error, aborted, vector_ready_unused,
        future_mode_a, future_mode_b]
  零命中:notice 非空时不得含 matches below|below are|entries below
  有条目:notice 非空时必须指向条目,且不得说 found nothing
  第二条以前完全没人管 —— W11/W11c 只查「出现 FAILED / cancelled」,
  不查它是否指向了真实存在的条目。

两条变异各自独立(实测,不是「都红就算数」)
  M-A 只删 aborted 分叉
    not ok 57 - W11f
    not ok 58 - W11f†
    ok     59 - W11f†b        ← default 那半仍绿
  M-B 只删 default 分叉
    not ok 57 - W11f
    ok     58 - W11f†         ← aborted 那半仍绿
    not ok 59 - W11f†b
  若两条变异下红的是同一批,那就是重复计数(W11d†a/†b 的老毛病)。

验证
- npm test 118/118
- npm run check 10/10
- 连跑 8 次全量,0 失败(按你的要求超过 5 次)
- 变异 runner 零残留:/tmp/mut-?????? 前后均为 0
  (/tmp/mut-probe 是 10:17 的历史副本,与本轮无关,未动)

未 amend / 未 force push,80d188e 之后是新提交。
2026-10-30 14:00:07 +08:00
80d188efc1 fix(memory-b): P0 no_embedder 逐轮注入 + 变异改为真改 lib/(并修掉自引入竞态)
P0 —— no_embedder 每轮注入(随 82b7546 进了远端)
复现(真链路,config 不给 embed = phase A 出货默认,10 个普通回合)
  10/10 回合被注入,共 2130 字符,均值 213 字符/次
注入原文是恒定、无条目的两行记忆头。

根因:W11d 拆两处短路时把 no_embedder 一起放行了。
问题不在「该不该说」,在「不该每轮说」——它是一条永久配置事实,
renderIndex()(systemPrompt.context)已经每轮在讲。
代价不只是 token:每轮一段恒定无条目的记忆头,模型的正确反应是学会跳过
该段,等真降级出现时也不看,正好废掉 W11 的用意。

修法
- no_embedder 返回 ''(注释写明理由与被测数字)
- 顺手修文案矛盾:embed_error + 零命中原来会说「matches below are…」
  但下面没有条目。现在分两种措辞:
    零命中 -> "FAILED … and the lexical fallback found nothing"
    有命中 -> "FAILED …; matches below are lexical/symbolic only"
- aborted / embed_error 不动

判据 W11e:默认配置(无 embedder)+ 零命中 ⇒ 注入必须为 ''
修复后同一探针:10 个回合 0 个被注入,0 字符
W11e 与 W11d 互为反向守卫:真·本轮事件要说,持续状态不该每轮说。

变异纪律(你指出的第四次同型,我认)
W11d†a / W11d†b 原来是测试里自己拼的局部函数,不碰 lib/ ——
真回退下它们保持绿,只有 W11d 变红。已删除,不留作计数。
换成三条真变异:W11e† / W11d† / W11d†ctx,
都真改源码、import 变异版、断言行为翻转。

★ 改真变异时我自己引入了竞态,并被抓到
第一版 withMutatedSource 直接在磁盘上改写 lib/recall.js 再还原。
node --test 是并行跑测试文件的,别的文件正在同时 import 它。
实测 1/30 左右偶发失败,症状看起来像「断言 flaky」,其实是 harness 竞态。
已改为 withMutatedCopy:整体复制 lib/ + package.json + 软链 node_modules
到临时目录,在副本上改、在副本上 import,完全不碰共享树。
验证:连续 30 次全量 0 失败;变异锚点漂移时显式报错(不会静默通过)。

文档
- README:W11e 的理由与实测数字、变异纪律(必须改 lib/,改 test/ 不算)、
  以及上面这条「变异共享状态不能与代码并发跑」的竞态记录
- docs/phase-b-criteria.md:W11e 进表、W11d 的变异列改为两条独立真变异、
  ★★ 变异纪律段(同型四次:W11†/W11d†a/W11d†b/W12†)

验证
- npm test 115/115
- npm run check 10/10
- 连跑 3 次 test + 3 次 check 全绿
- 另跑 30 次全量确认竞态已除(0 失败)
2026-10-30 13:34:18 +08:00
82b754661d fix(memory-b): W11d 两处短路 + W12 abort/失败分流 + 文档订正
W11d —— 你说得对,不是一行。两处短路各能独立吃掉提示
  ① lib/index.js contextForStep: `if (hits.length === 0) return ''`
  ② lib/recall.js renderRecall:  `if (!Array.isArray(hits) || hits.length === 0) return ''`

修法(按你给的形态):「有没有条目」与「有没有话要说」分开判:
    const notice = degradationNotice(hits)
    if (lines.length === 0 && notice === '') return ''
不写成「notice 非空就总出 header」——那样健康路径零命中回合每轮灌一行记忆头,
白烧 token 还教模型忽略这一段。

四场景实测(真 Context/SystemPrompt/ToolRuntime/真 adapter/真 http)
  A 降级+命中   -> empty=false failed=true  entries=true   54ms
  B 降级+零命中 -> empty=false failed=true  entries=false  51ms   ← W11d
  C1 健康+命中  -> empty=false failed=false entries=true    7ms
  C2 健康+零命中-> empty=true  failed=false entries=false   2ms   ← 正确沉默
与你实测四条一致。

W11d 判据 + **两条独立变异**
  W11d†a 只把 renderRecall 短路改回去  -> not ok 50 - W11d
  W11d†b 只把 contextForStep 短路加回来 -> not ok 50 - W11d
两条分别实测,证明「只修一处无效」,也证明每处都有人守着。

W12 abort 与 embed_error 分流(你上一条四项里的 ③)
原来两者都报 embed_error ⇒ 用户主动取消时提示「搜索失败」,会诱导无谓重试。
现在:options.signal.aborted 或 AbortError ⇒ 'aborted',其余 ⇒ 'embed_error'。
degradationNotice 新增 aborted 分支,措辞是「was cancelled」而不是「FAILED」。
W12 判据:两种标签、两种文案、必须不同。
变异(合并成 'embed_error')→ W10b / W11c / W12 三条同时红。
已同步 W10b/W11c/W11d 的断言(它们原假设 embed_error)。

② README:224 订正
`recall('etcd 端口', 5)` 已失效——第二形参变成 options 后,数字不是 {topK},
默认值生效。改为 `recall(q, { topK: 5 })`,并补 await(commit/recall 都是 async),
另加一段说明这个静默失效,以及「不 await 也能写」正是 W6 守着的脆弱假设。

④ docs/phase-b-criteria.md 补 W 组(原 0 命中)
新增 §W 表:W1/W5/W4/W6/W7/W8/W9/W9b/W10/W11/W11d/W12,
每条写「逼出它的 bug」与变异,并记下这组的共同教训:
判据用替身/短路/自造形状时,验的是替身而不是真代码。
(W9 假 embedder、W11d 两处短路互相掩盖、V3b 自造 2 段 batch 是同一形态。)

★ 你那条附带发现我也记下了:探针把成功响应写成 {vector:[...]},
而适配器只认 {embedding} 或 {data[0].embedding}(lib/embed/http.js:199-202),
于是「健康路径」实际一直在走 embed_error,C1 误报 FAILED。
自造 mock 时形状必须照适配器真读的字段抄,否则验的是自己的 mock。

验证
- npm test 113/113(108 + W11d/W11d†a/W11d†b + W12/W12†)
- npm run check 10/10
- 提交前连跑 3 次 test + 3 次 check,6 次全绿
2026-10-30 11:24:17 +08:00
72f02b866a fix(memory-b): W10 读路径取消 + W11 降级可见
W10 读路径完全没有取消(真 bug,与 W9 同形)
复现(真 adapter + 挂死的本地 http,timeoutMs 压到 2000,50ms abort)
  recall_elapsed_ms = 2004
  decision messages = 2 (aborted after 50ms)
  in-flight provider calls = 1
生产口径 DEFAULT_TIMEOUT_MS = 30_000 ⇒ 每次 pre-step 都可能卡 30 秒,
用户取消毫无作用。

根因链
  lib/recall.js:437  embedText(String(query ?? ''))     ← 只有一个实参
  lib/index.js:237   recall(query, topK)                ← 没有 signal 形参
  lib/index.js:257   contextForStep(messages)           ← 没有 signal 形参
  lib/index.js:431   payload.signal 只被读了 .aborted 一次,之后不传

修复:signal 逐层透传
  agent/pre-step payload.signal → contextForStep(messages, signal)
  → recall(query, {topK, signal}) → recallBlocksHybrid(..., {signal})
  → embedText(text, options.signal)
修复后同一复现脚本:recall_elapsed_ms = 54

W10 判据(走真 hook 链,不是直调 seam)
  W10  真 adapter + 挂死 server + 50ms abort 走 pre-step ⇒ <500ms,
       且 degraded === 'embed_error'
  W10b 同一件事在 seam 层,防止将来重构 hook 链把回归藏起来
W10† 变异(删掉 options.signal 实参)
  not ok 44 - W10: aborting a pre-step interrupts the recall embedding promptly
    error: 'an aborted pre-step must not wait for the embedding provider; took 30001ms'
  not ok 45 - W10b
  恢复后 # pass 108 / # fail 0
  ★ 30001ms 正是生产口径那个 30 秒

W11 降级必须能被模型看见(你提醒的口径)
确认属实:renderRecall 原来只拼 header + 命中行,完全不渲染 degraded。
而且 hits.degraded 是 enumerable:false(A 组 golden 比较需要),
所以它对 JSON.stringify / Object.keys / 任何序列化日志都不可见 ——
断言这个属性等于什么都没证明。文本是模型唯一的信道。

新增:
- renderRecall 渲染降级提示行
- degradationNotice(hits) 导出,三分支(no_embedder / embed_error /
  vector_ready_unused=不打扰模型),default 兜底防未来新模式静默落到 ''
- embed_error 的措辞点明「此处没有记忆不等于没记录过」,因为降级时
  「没有证据」不等于「证据为无」

W11 判据
  W11  降级 ⇒ 渲染文本含 FAILED 与 not prove it is unrecorded,
       并顺带断言 JSON.stringify 确实看不见 degraded(说明为什么需要文本信道)
  W11b 健康召回不出现任何提示(不误报)
  W11c 端到端:aborted pre-step 的文本既有命中也有提示,且 <500ms
W11† 变异(renderRecall 去掉提示行)
  not ok 46 - W11
  not ok 48 - W11c
  恢复后 108/108

一处自我更正:W11c 初版没有任何实质断言(注释里写「text 可能为空,
所以只断言类型」),那是假测试。已改为种子一条符号路可命中的块,
断言文本非空、含 FAILED、含命中值、且 <500ms。

★ 顺带记录一次操作事故:改 renderRecall 时我的 Python 替换把整个
  文件匹配进去,lib/recall.js 被写成 53 MB。已 git checkout 恢复并重做,
  当前文件 521 行、syntax OK、全量测试通过。以后大段替换先备份。

验证
- npm test 108/108(102 + W10/W10b + W11/W11b/W11c)
- npm run check 10/10
- 提交前连跑 3 次 test + 3 次 check,6 次全绿
2026-10-30 11:01:22 +08:00
170bc5fde2 fix(memory-b): W9 —— adapter 的 embedText 丢掉 caller signal(真 bug)
复现(真 adapter + 本地真 http server,3s 回包,50ms abort)
  signal forwarded? outcome=RESOLVED vector=[0.1,0.2,0.3] elapsed=3012ms
  ★ caller abort ignored — signal dropped by embedText signature

根因:lib/index.js:173 调 embedText(text, options.signal),而
lib/embed/http.js:152 声明的是 embedText(text) —— 第二实参被 JS 静默丢弃。
生产语义:用户已取消,工具继续阻塞到 adapter 自己的 30s 超时,
而且最后还把向量写进库;isConcurrencySafe 的兄弟调用一起被堵。

为什么 99 条全绿
W5/W5† 用的是手写慢 embedder,它自己 addEventListener('abort') 了 ——
判据测的是「我希望的行为」,没测真实 adapter。与上一轮 V3b 自造
2 段 batch 是同一形态:替身站到了唯一可能出错的那段代码前面。

修复
- embedText(text, signal):用 AbortSignal.any([signal, controller.signal])
  融合两个取消源。两个语义都保留:
    · 用户取消要立刻停(实测 50ms -> 53ms)
    · provider 卡死也要停(实测无 signal 时 151ms 超时;
      有未取消的 caller signal 时仍 151ms 超时)
  Node >= 22.5 已有 AbortSignal.any,不手写 fuse、不轮询。
- 同步修 Embedder 的 JSDoc 与 interface 类型,把第二个参数写进契约

W9 判据(真链路,不用假 embedder)
  W9  caller abort 打断真实请求:耗时 < 500ms 且确实抛出
  W9b adapter 自身 timeout 在有 caller signal 时仍然生效
  W9c 端到端(真 server + 真 adapter + ctx.tools.execute):
      块仍写入、vector 为空、error.info.code === 'ABORTED'

W9† 变异(贴红绿原文)
  ★ 第一次变异不干净:只改签名,函数体仍引用 signal ⇒ 抛 ReferenceError
    ⇒ W9 看到「很快抛错」判为通过。这是假绿。
  干净的变异(签名单参 + 去掉 fuse):
    not ok 41 - W9: a caller abort interrupts a REAL http embedding call promptly
      an aborted call must reject, not resolve with a vector
    not ok 43 - W9c: end-to-end — aborted tool call still writes the block
  恢复后 # pass 102 / # fail 0

f. 同形问题全仓扫描结论(grep + 实参/形参比对,非「我觉得」)
  lib/ 里所有传到 Embedder 的调用点:
    lib/index.js:173  embedText(text, options.signal)   -> 形参 (text, signal) ✔
    lib/recall.js:434 embedText(String(query ?? ''))    -> 单参,合法 ✔
  自动比对脚本报了一处 withDiagnosis 4>3 的告警,手工核对为误报:
  6 处调用实参分别为 2/2/3/3/3/2,正则被 Object.defineProperty 干扰。
  结论:无其它「传了实参、对面签名不收」的调用点。

README:376 修正
原文写「exec.signal is forwarded into embedText」——参数确实传了但对面对掉,
比没传更糟(读文档的人会以为验过)。改为与代码一致的表述,
并注明「W5 只覆盖管道,适配器侧由 W9 覆盖」。
另记下变异必须干净这条:失败原因不对的变异不算证据。

验证
- npm test 102/102(99 + W9/W9b/W9c)
- npm run check 10/10
- 提交前连跑 3 次 test + 3 次 check,6 次全绿
2026-10-30 10:41:42 +08:00
c6595d2cf0 feat(memory-b): H 收尾 W1–W8 —— 信号转发、写侧校验、建中心入口
五条全部处理。第 5 条确认属实:saveSpaceCentroid 在 lib/ 里零调用点,
真实使用下中心表永久空着,票③ 的「dim unknown」会从边角变成常态。

W1/W5 信号转发(最硬的一条)
- execute(args, exec) → commit(input, {signal}) → embedText(text, signal)
- abort 时按「向量化失败」降级落盘:块写入、vector 留空
- 依据 dsh-tools/lib/types/index.d.ts:120-122

★ 一处我判错了,据实记录
W5 我原本断言「abort 时 isError === false」,实测是 true。查了实现:
dsh-tools/lib/index.js:3408 在工具成功后**再检查一次 signal**,
已 abort 就用 toolAbortedResult 覆盖成功结果。所以 isError 不归本插件控制,
断言它等于和 harness 对抗。改为断言**副作用**(块已写入、vector 为空),
并额外断言 error.info.code === 'ABORTED'。

W2 九个调用点补 await
scripts/checkpoints.js ×4、test/plugin.test.js ×5、test/loader.test.js ×2
(loader 两处保持并发语义用 Promise.all)。
原本绿是因为「无 embedder 时第一个 await 之前就写完 putBlock」——
这个隐式事实一旦被一行 await 打破,九处会静默变成「块没写」,
而且红会出现在离原因很远的地方(靠后面能否召回间接断言)。

W3 空指纹先判后嵌
原来顺序是 await embedText → putBlock 抛 → catch → 再 putBlock,
语义终点对但白付一次网络往返,且与 recall.js:430 的口径不一致。
改为 embed 之前判 space.fingerprint === ''。

W4 写侧维度校验
commit 现在检查 vector.length === space.dimension,不符按 embed failure 处理
(无向量落盘)。不落坏向量的理由:召回侧会按 skippedDim 静默跳过,
用户看到的是「记忆里有这条,但永远召不回来」。

W8 建中心入口(第 5 条)
新增 store.rebuildCentroid(fingerprint),移植 RebuildCentroid
(centroid_store.go:246-320):
  · 只取该空间的带向量行
  · 维度不符跳过不强制
  · 按 L2 归一化后累加均值
  · 均值范数 >= 0.6 才判 anomalous(centroid.go:87)
  · 计入 stats:meanLength / avgPairCosine / skippedRows
并新增 MEAN_THRESHOLD 常量。

★ W8 第一版判据是错的:我用完全共线的向量(meanLength = 1.0000)
  去测 0.6 阈值,把阈值改成 0.95 仍然通过。已改用 ±35° 锥
  (meanLength ≈ 0.879,落在 0.6 与 0.95 之间),并加断言
  「语料必须落在两阈值之间」,否则测试自身就失去区分力。

判据与变异(全部在 /tmp 干净副本上跑)
  W4  维度不符 → 块在、vector=''、fingerprint=''      | 删维度检查 → 红
  W5  abort → 块在、vector=''、elapsed<2s              | 不传 signal → 红(实测 5003ms)
  W6  无 embedder 时 {id,created} 与 putBlock 同形      | —
  W7  空指纹 → embedText 调用 0 次                      | 改为事后判 → 红
  W8  同空间重建、跨空间隔离、坏维度跳过、阈值判定      | 去空间过滤 → 3 条红
                                                        | 去维度过滤 → 1 条红
                                                        | 阈值 0.6→0.95 → 1 条红

未越界
- 四态计数(noVector/unknownSpace/spaceMismatch/embedFailure)留给 F/D
- recallBlocks 仍然同步、未接入 vectorScores(V 组的事)
- 票③「dim 来自中心元数据而非配置」继续有效;saveSpaceCentroid 仍在用
  dim ?? space.dimension,已在注释里标记为 C 组要区分「没建过」与
  「建过但维度取自配置」——声明不是证据

顺序按你的调整记下:H(完工) → V → C → F → D。
C 组准入条件已满足一半:机制(rebuildCentroid)有了,
「谁触发建中心」待 C 组定。

验证
- npm test 99/99(90 + W4/W5/W6/W7 六条 + W8 三条)
- npm run check 10/10
- 提交前连跑 3 次 test + 3 次 check,6 次全绿
2026-10-30 10:35:41 +08:00
2adcbb7387 feat(memory-b): 票② 单一来源强制 + 票③ dim=null 边界
票② 采纳,并补上代码层的强制(原实现只是测试碰巧传同一串)
之前 putBlock 与 saveCentroid 各自接受一个 fingerprint 参数,
测试恰好传同一个值,但插件本身没有任何机制阻止调用方各自重新派生。
现在改为结构性保证:
- StableMem.space() 是 fingerprint 的唯一来源(返回 embedder.info())
- commit() 自己调 embedText,并用 space().fingerprint 盖戳;
  调用方无法传入第二个拼写
- saveSpaceCentroid() **不接受 fingerprint 参数**,只能取 space() 的
- info() 在 adapter 内 memo 一次,进程内不重算(票②第三条约束)
- commit 在无 embedder 或 embedder 失败时仍写块(vector 留空),
  不编造零向量;工具返回里会标注 "(stored without a vector)"

判据(按你给的最低验收:集合断言)
  ticket②-strong: 服务路径写 3 块 + saveSpaceCentroid
    → SELECT DISTINCT fingerprint FROM memory_blocks ≡ graph_centroid 键
    → 且 loadCentroid(块侧串) 能查到
  ticket②-strong mutation: 中心侧用另一种拼写(?tenant=b)→ 块侧串查不到,
    且不抛错
  ticket②-strong: 规范化把 `…/embed` 与 `…/embed/` 归为同一空间
变异(/tmp 干净副本)
  中心键加后缀 "-x"        → not ok 55 - ticket②-strong
  commit 用 text.length 派生 → not ok 55 - ticket②-strong

票③ 边界:未知维度必须是 null,不能是 0
  ticket③: 有向量块、无中心行 → loadCentroid 返回 undefined(不抛)
    → 诊断投影 dim === null,且断言 != 0
  ticket③: 有中心行时才报数值维度(来自中心元数据,不是 length(vector))
  ticket③: 缺中心不改变召回行为(recallBlocksHybrid 仍走符号路;
    recallBlocks 仍同步)
变异(/tmp 干净副本)
  loadCentroid 缺行时伪造 dim:0        → 4 条判据红
  loadCentroid 缺行时抛错(HA 视为正常降级) → 同样 4 条红

按你三点落地约束执行,未越界:
- 四态计数(noVector/unknownSpace/spaceMismatch/embedFailure)留给 F/D,
  H 只提供窄 Embedder(embedText/info/close)+ schema + 迁移
- 未把 V/C 融进召回:recallBlocks 同步签名与行为未动(A1/A1b 仍绿)
- 旧库迁移保留且可重入:MIG1/MIG2/MIG3/MIG3b/MIG4 全绿,
  MIG3 仍在 npm run check 里(10/10),未覆盖 phase-A 存量块

验证
- npm test 90/90(84 + 票②三条 + 票③三条)
- npm run check 10/10
- 提交前连跑 3 次 test + 3 次 check,6 次全绿
- README 的 check 输出块重新粘贴真实输出(漂移守卫仍绿)
2026-10-30 10:32:04 +08:00
3adb5a7018 docs+test: README 计数漂移根治 + MIG3 进 check + MIG4 崩溃窗口核实
README 计数漂移(你指出的)
- README.md 的 checkpoint 计数写的是 8/8,实际 9/9(现已 10/10)
- 证据块只列了 V1/V2/V3/V3b/V4/V5 六条,漏了 V3b† / V3c / A5†
- 改为**粘贴真实输出**,并加一段说明「本节曾两次因手抄而漂移」
- 新增自动校验(test/phase-b-g-static.test.js):比对 README 声称的
  checkpoint 数与 scripts/checkpoints.js 里 `await checkpoint('` 的个数,
  以及粘入的 "N/N checkpoints passed"。实测变异:把 10 改成 11 → not ok 6

MIG3 进 check(你的建议,采纳)
理由同意:check 是给人看的证据链,而「存量库能不能用」最该被看见;
将来谁改 open()/migrate(),看 check 的人比看 test 的人多。
新增 checkpoint MIG3:手工构造 phase-A 形库(含旧 centroid 表)→
opened → 两表列齐 → 旧块可读且 FTS 命中 → putBlock 成功 →
saveCentroid/loadCentroid 成功。现 10/10。

边角情况(你让我自己判断)——查了,结论与你的推测不同,据实记:
崩溃窗口(rename 成功但 user_version 未写入)**不可达**,
但原因不是我一开始写的那个。我最初注释说「因为 DDL 先跑会重建
graph_centroid」,写完后按纪律去验证,把 open() 里 migrate/DDL 顺序
对调,全量测试**仍然全绿** —— 说明那个解释是错的。

真正原因:pragma_table_info 对不存在的表返回零行,
所以 centroidColumns.length === 0,整个 rename 分支在重试时直接被跳过,
根本不会第二次 RENAME,与 DDL 顺序无关。

已按事实改正 lib/store.js 的注释,并把这个「我写错又验掉」的过程留在
注释里(否则下一个人会照抄错误解释)。新增 MIG4 钉住该恢复路径:
构造「旧表已改名、新表未建、版本未盖戳」的状态 → open 不抛错 →
重建可用 → 旧表行数不变。

验证
- npm test 84/84(83 + README 漂移守卫)
- npm run check 10/10(+MIG3)
- 提交前连跑 3 次 test + 3 次 check,6 次全绿
- 变异全部在 /tmp 干净副本上执行

D 组可以开工。
2026-10-30 10:26:38 +08:00
afc98191a9 fix(memory-b): MIG3 graph_centroid 迁移后未重建(P0)+ P1 改重命名保留
⚠️ 顺序说明:本提交修的三条都在 f84011d 里已经发出去了。
你核的是 f84011d 之前的未提交工作区;H 组内容与迁移我都已推送。
所以 f84011d 本身带了这个 P0(phase A 库迁移后写不了中心表)。
不掩饰,记在这里。

P0:迁移把 graph_centroid 整张表弄丢
复现(三个库,结果与你完全一致)
  A) phaseA 库(旧 centroid,空表) → opened, uv=1, putBlock OK,
     saveCentroid THREW: no such table: graph_centroid
  B) phaseA 库(旧 centroid,1 行) → open() THREW: refusing to migrate ...
  C) 全新库                         → saveCentroid OK

根因:open() 里 DDL 先跑、migrate 后跑。DDL 对已存在的旧表是 no-op,
migrate 里 DROP TABLE 之后再也没有东西重建它。
A 是最糟形态:开得了、stamp 了 uv=1、能写块、FTS 能召回,
只有第一次 saveCentroid 才炸 —— 失败窗口最窄,所以 MIG1(只看
memory_blocks)结构上扫不到。

修法
- 把中心表 DDL 抽成 DDL_CENTROID,在 migrate 里 DROP/RENAME 之后
  就地 db.exec(DDL_CENTROID) 重建(不是靠前面那次 DDL)
- 新增 BlockStore.tables() 与 raw()(后者仅供测试做行数断言)

P1:B 分支不再硬失败,改重命名保留
原来 throw 让整个库打不开,而错误信息还指向一个不存在的出路
("export the data",但 phase A 从不写 centroid、也没有导出工具)。
裁定改为:有行则 RENAME 成 graph_centroid_phase_a_<ts>,启动继续、
数据不丢、旧行仍可用 sqlite3 查;空表则 DROP 后重建。
你说「如果认为保留旧表会造成 pragma_table_info 误判,说明理由再定」——
不会:命名带 _phase_a_ 前缀,且 migrate 只检查名字恰为
graph_centroid 的那张表。

MIG1 mutation 已删除(它冒充变异)
那条手工构造 raw DB、直接对 DatabaseSync 断言,一行 lib 代码都没碰,
永远不会随实现回滚变红。是演示不是守卫。已删,并在原位置留下说明:
真正的守卫是 MIG1 正向那条(注掉 ADD COLUMN vector 即红)。

判据与变异(全部在 /tmp 副本上做,按你的纪律)
  MIG3  phase-A 库 → 列齐 → saveCentroid 成功 → loadCentroid 读回
  MIG3b phase-A 库(1 行)→ 能打开、新表可用、旧表重命名且行数保留

  MUT-A 删掉重建 DDL(原 P0 形态)
    not ok 24 - MIG3   error: 'dsh-memory: no such table: graph_centroid'
    not ok 25 - MIG3b
    (MIG1/MIG2 仍绿 —— 精确复现「MIG1 看不到这张表」)
  MUT-B 有行改回 throw
    not ok 25 - MIG3b
  MUT-C 注掉 ADD COLUMN vector
    not ok 49 - MIG1
  MUT-D 完全去掉 migrate()
    not ok 49/50/51 - MIG1/MIG2/MIG3

验证
- npm test 82/82(81 - 1 条假变异 + MIG3/MIG3b 两条)
- npm run check 9/9
- 提交前连跑 3 次 test + 3 次 check,6 次全绿
- 所有变异在 /tmp/mvp-copy 副本上执行,未在共享树里改 lib

待办
- 票③ D7 fail-closed(中心表空但有向量行 ⇒ dim 报 null)跟 F 组
2026-10-30 10:22:25 +08:00
f84011d9ef fix(memory-b): phase A 存量库迁移 + 成功路径 degraded 标签断言
⚠️ 先说明顺序问题:H 组我已在 00a4a5f 提交并推送,在你这条指令之前。
你要求「迁移是 H 的前置」,我同意,但已经发出去了。
本提交补上迁移,00a4a5f..本提交 之间是一个真实的破窗状态
(存量库会被打成只读),已记在这里,不掩饰。

阻塞① 迁移(硬 bug,我复现了)
复现:用 phase-A 形库(无 vector/fingerprint 列,user_version=0)
  opened OK                        ← 加载不报错
  PUTBLOCK THREW: table memory_blocks has no column named vector
  blockFingerprints THREW: no such column: fingerprint
根因:DDL 全是 CREATE TABLE IF NOT EXISTS,对已存在表静默 no-op。

实现(lib/store.js)
- 新增 SCHEMA_VERSION = 1 与 migrate(db)
- 探测而非猜:读 pragma_table_info('memory_blocks'),缺哪列 ALTER 哪列,
  不靠 try/catch 吞异常(半个迁移被吞掉是最难查的形态)
- user_version 为 0 不等于缺列(phase B 崩溃前未盖戳的库会是这样),
  所以判据是列存在性,不是版本号
- graph_centroid:phase A 的空表直接 DROP 重建;有数据则**显式拒绝并报错**,
  不销毁数据
- 新增 userVersion() 供读取
- open() 在 DDL 之后调用 migrate():新库已具当前形状,只有老库有活干

判据
  MIG1  phase-A 库:列补齐 → 老块 getBlock 读得到 → FTS 仍命中
        → putBlock 成功 → user_version==1 → 重开幂等
  MIG2  新库直接是 version 1
  MIG1† 不迁移时那条真实写入必须失败(并断言 phase-A 表确实无 vector 列)

变异打红原文(把 migrate(db) 注释掉)
  not ok 21 - MIG1: a phase-A database gains the vector columns and stays writable
    error: 'missing vector column; have id,modality,text_content,source,scene,semantic_type,created_at,updated_at'
  not ok 22 - MIG2: a fresh database is created at version 1
恢复 → # pass 23 / # fail 0

★ 我第一版 MIG1† 写错了:用了一条不提到 vector 的 INSERT 去断言会失败,
  而那种 INSERT 本来就会成功。已改成 phase B 实际执行的那种写(显式带
  vector/fingerprint),并加断言确认 phase-A 表确实没有该列。

阻塞② 成功路径的 degraded 标签
你复现过:把 lib/recall.js 的 'vector_ready_unused' 改回 'hybrid',
76 条全绿 —— 成功路径的标签没有任何断言。已补:

  A3c: 健康 embedder ⇒ degraded === 'vector_ready_unused',
       且断言 != 'hybrid',并确认 embedText 真被调用了一次
  A3c mutation: 交换常量必须抛 ERR_ASSERTION

真实代码变异(把标签改回 'hybrid',跑全量)
  not ok 12 - A3c: a healthy embedder reports "vector_ready_unused", NOT "hybrid"
    error: 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
    expected: 'vector_ready_unused'
  # tests 81 # pass 80 # fail 1
恢复 → # tests 81 # pass 81 # fail 0

端到端复验(真实 phase-A 库过一遍插件)
  loaded on phase-A DB, user_version = 1
  commit: Committed block blk_5e3653f55544.
  legacy block recallable: true -> 值班室分机号 4324

验证
- npm test 81/81(76 + MIG 三条 + A3c 两条)
- npm run check 9/9
- 提交前连跑 3 次 test + 3 次 check,6 次全绿

待办
- 票③ D7 fail-closed(中心表空但有向量行 ⇒ dim 报 null)跟 F 组
2026-10-30 10:20:10 +08:00
00a4a5f6ee feat(memory-b): G2† 编成 test + H 组完成(http adapter + 两表指纹一致)
G2† 补成自动化(你坚持的那条,我认)
你指出 G 组只有三条变异是自动化断言,G2† 只活在我的 commit message 里 ——
手工记录的变异不会随时间复现。已编成 test,并扩到覆盖**每一条**降级路径:

  DEGRADATION_PATHS 数据表(6 条,新增第五条时未覆盖则测试即红)
    embedder is null / undefined / 无 embedText
    ★ 无 info()                      ← 你点名的那条
    ★ info() 的 fingerprint 为空串
    ★ info() 的 fingerprint 非字符串
  + 每条都断言 degraded=no_embedder、仍能召回、且零 fetch
  + 额外一条「指纹检查在 embedText 之前」(钉顺序,不只钉结果)

变异实测打红
  把 embedText 调用挪到指纹检查之前
    not ok 8 - G2†: the fingerprint check runs BEFORE embedText is called
  让无指纹分支发一次 fetch
    not ok 5 - G2†: "embedder with no info()" ... zero network calls

H 组实现(lib/embed/http.js,单文件,G5 守卫)
- 协议与 HA 逐字段一致:model / modality / side / text;响应两种形状都收
- 指纹 http:<model>:<dim>:<sha256(规范化 endpoint)[:8]>
  规范化(hash 之前):去空白 / scheme+host 小写 / 去尾斜杠 / 去默认端口
  不做 query 重排与 .. 解析(那些差异通常是真实的)
  ★ 构造时算一次并复用 —— 因为同一个串必须同时进两张表
- 失败在 adapter 层是硬错误(非 2xx / 无 embedding / 维度不符 / 超限 / 超时),
  在召回层被 catch 成 degraded='embed_error',退回 FTS5
- 维度不符绝不截断不填充(block_recall.go:135-138)

票② 两表指纹一致 —— 落在 H 组 schema,未拖到 F
- memory_blocks 加 vector / fingerprint 两列(phase A 的表已发布,
  这里用 CREATE TABLE IF NOT EXISTS 的新列,旧库需 ALTER;已记入待办)
- graph_centroid 列名对齐 HA(vector_count / vector / stats / built_at),
  并把 is_anomalous 提成独立列(票③口径),不塞进 stats JSON
- putBlock 拒绝「有向量但无指纹」——无标签的向量永远不可安全比较
- putBlock 重提交不带向量时不清空已有向量(与 PutMemoryBlocks 的空值不覆盖同构)
- 新增 saveCentroid / loadCentroid / blockFingerprints

票② 判据
  ok - ticket②: block fingerprint and centroid fingerprint are byte-identical
  ok - ticket② mutation: a re-derived centroid fingerprint misses the lookup
       (加一个尾随空格即查不到,且不抛错 —— 正是必须用断言钉住的原因)
  ok - ticket②: a vector without a fingerprint is refused at write time
  ok - ticket②: re-committing without a vector must not erase the stored one

H 组变异(8 条,全部实跑打红)
  HM1 side 写死 document        → not ok 1 - H1
  HM2 指纹去掉 endpoint hash     → not ok 10/12/13 - F-fp1/fp3/fp4(三条一起红)
  HM3 维度不符改为截断           → not ok 5 - H3b
  HM4 去掉响应体积上限           → not ok 9 - H6
  HM5 保留尾斜杠                 → not ok 11 - F-fp2
  HM6 非 2xx 当成功              → not ok 4 - H3
  HM7 忽略 timeout signal        → not ok 8 - H4
  HM8 指纹每次 info() 重算       → not ok 15 - F-fp6

一处设计确认:lib/index.js 现在 import './embed/http.js',
G5 仍绿 —— 因为该文件没有 fetch/Authorization 等 transport 标识符。
「可以 import adapter,不可以知道 transport」正是 G5 的设计意图。

验证
- npm test 76/76(56 + H 组 20)
- npm run check 9/9
- 提交前连跑 3 次 test + 3 次 check,6 次全绿

待办(不催,记着)
- 票③ D7 fail-closed(中心表空但有向量行 ⇒ dim 报 null 不报 0)跟 F 组
- phase A 旧库升级需 ALTER TABLE 加两列,随 D 组的 stats 工具一起做
2026-10-30 10:17:18 +08:00
600df5b817 feat(memory-b): G 组完成 —— 5 条守卫 + 两条你要求的即时修正
先落你要求的两处修正(在 G 组之前)
1. A1 加硬:新增 A1b,用 `git show 885e0b6:lib/recall.js` 取冻结版,
   逐字节比对 recallBlocks 的函数体区间。
   实测有效:把 `String(query ?? '').trim()` 改成 `(query ?? '').toString().trim()`
   (行为等价、仍同步),A2 golden fixture **仍绿**,但 A1b 红 ——
   正是你指出的「挡不住顺手优化同步版」的缺口。
2. `degraded: 'hybrid'` → `'vector_ready_unused'`。
   现在语义是「向量算成功了但没进评分」,叫 hybrid 是骗人的。
   已同步改 README,并注明 V 组接上后再改名。

G 组判据(test/phase-b-g.test.js 行为 3 条 + test/phase-b-g-static.test.js 静态 2 条)
- G2  无 embedder ⇒ 零网络调用(替换 globalThis.fetch 为 tripwire)
- G2b lib/ 只 import node: 内建与 dsh peer
- G3  embedder 抛错时 stablemem_commit 仍写入,符号路仍能召回
- G4  只有 text 模态,memory_blocks 不存在 media 列
- G5  transport 标识符(fetch( / Authorization / Bearer / http:// /
      model_dir / api_key)只许出现在 lib/embed/ 下

G 组变异(全部实跑打红,原文)
  GM1 无 embedder 分支加 fetch
      not ok 1 - G2: with no embedder, recall performs no network I/O
  GM2 commit 先 await embedder,失败则抛
      not ok 3 - G3: stablemem_commit writes even when the embedder throws
  GM3 memory_blocks 加 data BLOB 列
      not ok 4 - G4: only the text modality is produced
  GM4 recall.js 里写 endpoint 字面量
      not ok 5 - G5: provider-specific identifiers may appear in exactly one lib file
  GM5 lib/store.js 加 import onnxruntime-node
      not ok 1 - G2b: lib/ imports only node: builtins and cordis/dsh peers

★ G 组自己踩到的两个坑(都是判据本身的缺陷,不是 lib 的)
1. 静态扫描判据不能 import 被扫描的代码。G2b/G5 原本和行为判据放同一文件,
   而那个文件 import 了 lib/index.js ⇒ lib/ 一坏整份测试崩,
   报的是 "load failed" 而不是「你加了不该加的依赖」。
   已拆到 test/phase-b-g-static.test.js(不 import 任何 lib/),
   实测:lib/store.js 加 onnxruntime-node 后模块加载失败,
   静态判据仍独立报出 G2b 红。
2. 扫描必须去注释。G5 第一版把 recall.js 注释里的「no endpoint」
   和 store.js 的「edge endpoint」当成违规。误报的守卫会被关掉,
   比没有更糟。已改为先剥注释再扫。

顺带:为 G4 的 schema 内省加了 BlockStore.columns(table)(公开方法),
而不是让测试去碰私有 handle。

未做的事(等你裁定后再动)
- F 组:等 §0.1 endpoint 规范化 / §0.2 四态 / §0.3 stats+is_anomalous
  独立列 / §0.4 降级落地
- 已记录你指出的关键一处:HA 的指纹守卫有**两处**
  (block_recall.go:140 与 fusion.go:495),同一个三连 &&,
  空 fingerprint 在向量路和符号路上都会被放行。
  F 组判据必须覆盖符号路,只测向量路等于没测。
  我核过 fusion.go:481-495 的注释与 toolcall_block_test.go:124 的起因,
  确认属实。

验证
- npm test 46/46(40 + G 组 5 + A1b)
- npm run check 9/9
- 提交前连跑 3 次 test + 3 次 check,6 次全绿
2026-10-30 10:09:45 +08:00
98612b2746 feat(memory-b): A 组完成 —— 异步 seam + golden fixture,recallBlocks 未动
G6 收口
- 范围如实收窄:表从 8 个扩到 **23 个**(当前 harness 里全部 user-role
  注入 kind),并把措辞改成「installed harness 里的 user-role 注入 kind」,
  不再暗示穷举。全量 MessageSourceMap 有 25 个 source key,会继续增长;
  白名单是 fail-closed,未列出的新 kind 天然安全。
- 每个 kind 都核到字面声明(不是照抄之前的表);`compact-checkpoint` 的
  kind 藏在 checkpoint.d.ts:18 的 marker 类型里,也已核到。
- 新增 **G6b**:`user-rpc` 的 kind 就是 `'user'`
  (dsh-api-session-controller/lib/types/types.d.ts:395-400),
  所以这条白名单不会把远程/网页用户挡在外面。这条是你指出的,
  我认为是全组最关键的安全性断言,已写进 README 正文。

A 组实现
- `recallBlocks` 保持同步且一行未改(新增函数在它之后)。
- 新增 `recallBlocksHybrid(store, embedder, query, opts)` async 包装。
  无 embedder / embedder 抛错 / 返回空向量 / 维度与 info() 不符
  ⇒ 退回同步召回,并在返回数组上挂不可枚举的 `degraded`
  ('no_embedder' | 'embed_error' | 'hybrid')。永不抛错(§0.4)。
- `degraded` 不可枚举:不进 JSON.stringify / Object.keys /
  与 phase A 输出的深比较,保证 A3 的逐字段相等成立。
- `Embedder` 窄接口三成员:embedText / info() 同步 / close。
  判据里不出现任何 provider 字段名。

A 组判据(test/phase-b-a.test.js,6 条)
- golden fixture:12 块 × 15 查询 × topK 1/3/5 = **45 例**,
  逐例比较 id / 顺序 / **score 精确相等(无容差)** / exact / why 字符串。
  容差比较会放过评分改动,而这正是本组要挡的。
- A1 结构性断言 recallBlocks 不返回 thenable。
- A3 hybrid(null) 与同步版逐字段相等(含 why)。
- A3b 降级原因可见。

A 组变异(全部实跑打红,原文如下)
  M1 非精确带分数 +1e-6
     not ok 2 - A2: golden fixture — every case reproduces exactly
  M2 recallBlocks 改 async(被否决的设计)
     not ok 1 - A1: recallBlocks is synchronous and takes no embedder
  M3 hybrid 给 why 加后缀
     not ok 3 - A3: recallBlocksHybrid with no embedder equals recallBlocks

★ A 组抓到的真问题(不是我的判据,是实现改动引起的)
`StableMem.recall()` 变成 async 后,上一轮那条 REGRESSION 变异测试
拿到的 Promise 没有 `.length` ⇒ `undefined < undefined` 为 false ⇒
断言「通过」但什么都没测。checkpoint V3c 同一处也有这个问题。
两处已补 await。README 里记成一条通用陷阱:
任何东西改 async 后,grep 所有对可能已变 Promise 的值取 `.length` 的地方。

验证
- npm test 40/40(34 + A 组 6)
- npm run check 9/9
- 提交前连跑 3 次 check + 3 次 test,6 次全绿(按你的要求)

F 组未动:等 §0.1 endpoint 规范化 / §0.2 四态 / §0.3 stats+is_anomalous /
§0.4 失败降级的裁定落地。
2026-10-30 10:05:32 +08:00
64c614b99a fix(memory): 自动召回在真实 dsh batch 里 100% 失效
实测发现的 bug(在 headless profile 里真跑出来的,不是判据抓到的)。

根因
lastUserText 取「最后一条 role=user」。但真实 step batch 里,
用户提问后面还跟着 user 角色的注入:
  [ 用户提问 ][ runtime-context 快照 ][ skill-catalog 提醒 ]
于是拿技能清单当检索词,召回 0 命中。

为什么 V3b 全绿却漏了:V3b 自造 batch 只有 2 条消息,判据有盲区。

修复
lastUserText 改为按 source.kind 过滤,allowlist 只有人真实提问的
kind(MessageSourceMap.user 的 'user')。未知 kind 一律当作注入——
将来某个插件的快照绝不能变成检索词。

  实际给 dsh 的注入 kind:
    runtime-context      dsh-agent-loop 的工作目录/沙箱快照
    skill-catalog        dsh-tool-skill 的技能提醒
    skill-invocation     同上
    agent-instructions / time-context / goal / ...

判据(先写红,再修)
- REGRESSION: lastUserText 从 3 段 batch 里取出真实提问        修前红
- REGRESSION: 真实 batch 端到端必须召回命中(修前 0 命中)      修前红
- REGRESSION 变异: 退回「最后一条 role=user」⇒ 命中数必须掉到 0  修前红
- checkpoint V3c: 同样的场景进 checkpoints,V3b 的盲区补齐
- 原 V3b 的 fixture 补上 source.kind(真实消息都有,缺了就没测到判别)

修前/修后数字(4 条种子块,三种 batch 形状)
  batch 形状                                旧规则  新规则
  2 段(原判据形状)                            1       1
  3 段(+runtime-context)                      0       1
  4 段(+runtime-context +skill-catalog)        0       1
  合计                                          1/3     3/3

在实测者留下的真实库上复验(/tmp/dsh-memory-probe/memory.db,
1 条真实块「测试口令是 7788。」):
  旧规则 query = "[Skills] git-release-discipline, ..." → 0 命中
  新规则 query = "测试口令是什么"                        → 1 命中

验证:npm test 29/29(原 26 + 3 条回归)、npm run check 9/9(原 8 + V3c)。

README 补「真实 batch 不只是提问」一节,含 kind 对照表与修前修后数字,
并把 V3b 的盲区显式标注为已知缺口。
2026-10-30 09:55:14 +08:00
114fae84aa feat(memory): MVP 阶段 A 切片 —— 块进 dsh、召回、进 prompt
按 docs/feasibility.md §7.1 阶段 A 落地最小可验证切片,
刻意不引入 embedding、不碰 L1 淘汰。

落地内容
- lib/store.js   BlockStore:node:sqlite 建 memory_blocks /
                 memory_block_edges / graph_centroid 三表 + FTS5 索引。
                 内容派生 id(blk_+sha256[:12],移植 distill.BlockID);
                 putBlock 走「空值不覆盖」,重复 commit 幂等。
- lib/recall.js  FTS5 bm25 + 精确串布尔置顶(移植 SymbolScore/fusion.go:427)、
                 单字符不成符号(abstain.go:115)、前缀特异性 tie-break、
                 CJK 2..4 字滑窗兜底(移植 cjkWindows/fusion.go:240)。
- lib/index.js   StableMem service + systemPrompt.context 常驻索引 +
                 agent/pre-step 按本轮输入召回注入 + stablemem_commit 工具。
- scripts/checkpoints.js  V1–V5 + 两条变异,对真实 cordis 服务跑。
- test/          26 个测试(store / recall / plugin wiring / real loader / isolate)。
- scripts/link-dsh.sh     把全局 dsh 包软链进 node_modules(插件本身零依赖)。

三个实测出来的 dsh 约束(都是先红了才发现的)
1. cordis service 不能用 JS #private 字段 —— ctx.<name> 是 proxy,
   访问私有字段抛 "Cannot read private member"。状态必须放普通属性。
2. isolate() 不隔离 prompt context 名与工具名(只隔离 service 查找)——
   所以工具名做成可配置,prompt context 名由 db 路径派生以保唯一。
3. systemPrompt.getContextOrder() 是分配器不是常量表,用命名槽位。

验证
- npm test  → 26/26 通过
- npm run check → 8/8 checkpoint 通过
  V1 sqlite/WAL/100 写回读;V2 能力分层(tier1 30/30、tier2 20/20,
  tier3/4 作为已知缺口显式记录);V3 AssembleContext 仅 [agent];
  V3b 注入落进批次且带 source.kind;V3b† 静默召回则判据红;
  V4 工具 schema+render 端到端;V5 fiber dispose 释放句柄并可重开;
  A5† 退化成包含匹配则区分度消失。

阶段 A exit criteria 全部满足(含 V3/V3b 分工结论写入 README)。
参考仓 /home/program/TrueAgent 未改动。
2026-10-30 09:44:42 +08:00