77 Commits

Author SHA1 Message Date
f3ee2d1cb0 test(memory): pin that the WRITE path marks stale, not the test (C4 follow-up)
C4j asserted that a stale-marked centroid falls back to uncorrected vectors — but it
called `store.markCentroidStale(fp)` **itself** to set up the state. That proves the
recall side honours the flag; it says nothing about whether production ever sets it.

Measured: deleting the one line in `#ensureCentroid` that persists the verdict

    if (status?.stale === true) this.store.markCentroidStale(fingerprint)

left **all 306 criteria green**. The wiring had no guard at all.

This is the fifth instance of one shape in this branch:

    W8   saveSpaceCentroid defined, zero production callers
    C4d  centroid built by the test's own rebuildCentroid call
    P1   vectorScores hand-built in the fixture
    D2   counters computed, never printed
    C4j  stale flag set by the criterion, not by the write path

A hand-fed input cannot prove a production path. So C4k drives only the production
entry points — `apply()` + `commit()` — and marks nothing itself:

    after a settled rebuild   stats.stale === undefined
    after 3 more commits      stats.stale === 1     (drift 3/8 = 37.5% > 20%)

Mutation — deleting that line in `#ensureCentroid`:

    not ok 12 - C4k: the write path itself marks a drifted centroid stale
    error: C4k: the WRITE path must persist the stale verdict ... undefined !== 1

## Why the drift is generated rather than forced

3 commits on top of a settled 8 puts the delta at 37.5%, past the 20% ratio — the
same path an ordinary growing space takes. Forcing `markCentroidStale` directly is
exactly what C4j does and exactly what this criterion exists to complement.

Gates: npm test 307/307 (0 skipped), npm run check 12/12, frozen faces untouched.
2026-11-05 12:39:06 +08:00
7652184832 fix(memory): honour the C4 order — a stale round recalls with UNCORRECTED vectors
The C4 slice instruction said, verbatim: "判 stale 的那一轮仍返回结果(用未校正向量)".
It was not implemented. `grep stale lib/recall.js` returned one comment and no code,
and `loadCentroid` carries no staleness verdict — so recall centred with whatever
mean it found, however old.

## Why not simply ask on the recall side

The obvious fix — call `centroidStatus` in `recallBlocks` — was built and measured:

    # tests 305  # pass 304  # fail 1
    not ok 97 - C3-k: the recall hot path must not run a COUNT query
      error: 'lib/recall.js must not call centroidStatus — it runs countVectored
             internally, and the recall path may only read the centroid'

C3-k is a rule this project set deliberately. A fix that breaks it is not a fix,
and the criterion caught the attempt — which is what it is for.

## What this does instead

The WRITE path already pays for the judgement on every commit (`#ensureCentroid`
calls `centroidStatus`). So it persists the verdict:

  · `BlockStore#markCentroidStale(fp)` — `json_set(stats, '$.stale', 1)` when the
    space is judged stale
  · `#ensureCentroid` marks on `status.stale === true`
  · `recallBlocks` honours it: `!centroid.stats?.stale` before centring
  · cleared implicitly — `rebuildCentroid` → `saveCentroid` upserts stats without
    the key

Recall never runs a COUNT; C3-k stays green.

## The bug this shipped with for one revision — and why it matters

The guard was first written `centroid.stats?.stale !== true`. The flag is stored as
the JSON number `1`, and in JS **`1 !== true` is true** — a strict comparison across
types — so the guard was a permanent no-op. Stale centroids kept being used.

**The entire suite was 305/305 green with that bug in place.** Nothing covered the
path. It was only visible on a fixture where centring is observable (the C4
hub/target space):

    fresh centroid    top1=target  0.9000 / 0.2129 / 0.1814
    marked stale      top1=hub     0.9928 / 0.9859 / 0.9803   <- raw cosine, uncorrected
    after rebuild     top1=target  0.9000 / 0.2129 / 0.1814

Hence C4j (below) rather than a comment claiming it works.

## Criterion C4j

Three assertions on that fixture: fresh → target; marked stale → hub AND the score
is the raw cosine (0.9928); rebuild → target again. Mutation — deleting the guard:

    not ok 11 - C4j: a centroid marked stale falls back to uncorrected vectors
    error: ... If this still says "target", the flag is being ignored — the exact bug
           that a strict `!== true` comparison produced while the whole suite stayed green.
           'target' !== 'hub'

## What this does NOT establish

That falling back improves ranking quality. An attempt to measure it could not
separate the cases: with a near-duplicate late corpus, scores saturate near 0.99
whether the mean is stale (0.997) or absent (0.994); only a freshly rebuilt mean
spread them (0.978..0.970). So this is a safety choice — a mean built from a
fraction of the space describes a space that no longer exists, and applying it is
unjustifiable on principle. The measurable win belongs to keeping the centroid
FRESH (the throttle in C3), not to skipping it.

Gates: npm test 306/306 (0 skipped), npm run check 12/12, frozen faces untouched.
2026-11-05 12:34:29 +08:00
59e6a71f28 perf(memory): stop re-reading 79 MB of vectors to answer a one-bit question
`recallBlocks` unions symbol matches with vector candidates, and to do that it walks
the whole table. It used `allBlocks(-1)` — `SELECT *` — while the only thing it
needs from the vector column is whether one EXISTS.

Measured at 8000 blocks x 512 dims, per recall turn:

    allBlocks(-1)  SELECT *          242.8 ms
    this read, narrow                 22.2 ms
    ------------------------------------------
    whole recallBlocksHybrid   624.0 -> 409.6 ms   (-34%)

At 2000 blocks: 157.6 -> 106.0 ms (-33%). The saving is not marginal and it is on
the per-turn pre-step path, which runs on every message.

## What changed

`BlockStore#symbolCandidates()` returns the six columns the pool actually reads
(id, text_content, source, fingerprint, vector_dim) plus `(vector != '') AS
has_vector`. `recallBlocks` iterates that instead of `allBlocks(-1)`, and the pool
entry takes `hasVector: block.has_vector === 1`.

`allBlocks` itself is untouched — the bounded symbol-only path still uses it with
`poolSize`, and tests use it as the wide reference.

## Why this needed a criterion rather than a green suite

A narrower read is a behaviour change waiting to happen, and the suite would not
notice most of them: 304 criteria were already green before it. So the criterion
asserts the equivalence directly:

  · same row set in the same order (the pool is a Map; insertion order is iterated)
  · `has_vector` matches the predicate it replaced — `String(vector ?? '') !== ''` —
    block by block, on a corpus containing a real vector, no vector, and an empty
    payload

Mutation — `1 AS has_vector`:

    not ok 7 - narrow read: symbolCandidates() sees exactly what allBlocks(-1) saw
    error: has_vector must reproduce the predicate it replaced, for block blk_01db9be26e52
           1 !== 0

## One divergence deliberately left alone

The literal string `'null'` counts as "has a vector" under the old predicate, while
`vectorCandidates` excludes it. That inconsistency predates this commit and is
documented rather than silently fixed: changing it is a separate behaviour change
that needs its own criterion, not a footnote in a performance commit.

Gates: npm test 305/305 (0 skipped), npm run check 12/12, frozen faces untouched.
2026-11-05 12:20:52 +08:00
73a0a6b8d0 test(memory): pin D1 — a corrupt vector cannot take the recall path down (D1)
D1 was the one item on the coverage list with genuinely no criterion. Measured by
mutation, not by grepping test names:

    corrupt vector handling: skip -> throw
    phase-b-h 137/137 green, phase-b-a 38/38 green, recall-accounting 5/5 green
    -> 145 criteria, zero red

That is decision §0.4 unguarded: "the vector side failing must not take the main
path down". One bad row would cost the whole turn its memory, and nothing noticed.

## The same probe found three items that ARE covered

Recorded because the parent's coverage matrix derived "zero hits" from grepping
criteria IDs against test names, and three of those were false negatives — the
criteria exist, they are just named after their subject rather than their ID:

    C6  centroid partitioned by fingerprint   loadCentroid ignoring it -> 3 red
                                              (ticket②, ticket②-strong, W8)
    W1  exec.signal reaches embedText         dropping it -> 2 red (W5, W9c)
    F1  fingerprint from provider info()      deriving locally -> 5 red

So the matrix under-reports as well as over-reports. Only mutation distinguishes
"labelled" from "enforced".

## What the criterion does

Writes a corrupt payload straight into the row and requires the recall to survive:

    store.raw().prepare('UPDATE memory_blocks SET vector = ? WHERE id = ?')
      .run('["a","b","c","d"]', broken.id)   // right length, non-numeric elements

Two guards narrowed what "corrupt" can mean, both measured and documented in the
criterion:

  · DDL enforces `json_valid(vector)` — a syntactically broken payload is refused
    outright (`ERR_SQLITE_ERROR: malformed JSON`), so it cannot reach the read path;
  · a trigger re-derives `vector_dim`, so a wrong-LENGTH payload is filtered in SQL
    before scoring sees it.

An array of the right length with non-numeric elements clears both and still cannot
be scored — which is exactly what a provider that changed its encoding, or a
migration that stringified values, would leave behind.

Assertions: no throw, the healthy neighbour is still recalled, and the bad row is
counted in `skippedUnscoreable` (D1's other half is D2's rule: skipping silently is
the same defect).

Mutation — a non-numeric component throws instead of skipping:

    not ok 6 - D1: a corrupt vector payload cannot take the recall path down
    error: D1: a single corrupt vector payload threw out of the recall path
           (M-D1: non-numeric vector component). Decision §0.4: ...

Gates: npm test 304/304 (0 skipped), npm run check 12/12.
2026-11-05 12:13:17 +08:00
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
5462934a84 test(memory): pin the reason at the TOOL surface, the fourth place it can vanish (C5)
C5's reason now has three layers of coverage:

  1. store — six fixtures, one per cause (`centroidStatus`)
  2. plugin — `stats().centroid_state`, read-only (no rebuild scheduled)
  3. tool — `ctx.tools.execute('stablemem_stats')`

Layer 3 is the one that matters in dsh: the model does not call `mem.stats()`, it
calls the tool. And the tool's handler is a single `return mem.stats()`, so a
later refactor that cherry-picks fields would keep layers 1 and 2 green while the
only surface anyone actually reaches goes silent.

That is the fourth instance of one shape in this branch:

  P1   queryVector computed, dimension-checked, then dropped
  D2   drop counters computed, then not printed
  C5   the reason computed, read by nobody
  C5   the reason surfaced on the service, but not pinned at the tool

Each time the value existed, the criteria were green, and no operator could see
it. So the criterion asserts on the tool's own envelope.

## Criterion

Drives the real ToolRuntime exactly as `test/phase-b-h.test.js` does, with a real
`AbortSignal` (`ToolRuntime` reads `callerSignal.aborted`, so `undefined` throws):

    empty space        -> centroid_state present, reason 'no_centroid'
    after 8 commits    -> reason 'ok', available true, sibling fields intact
    drift past ratio   -> reason 'stale', rebuildScheduled FALSE

The last assertion doubles as the read-only proof at this layer: a tool an
operator polls must not drive the rebuild loop.

Mutation — handler returns a cherry-picked subset:

    not ok 10 - C5: the stats TOOL returns the reason, not just the service method
    error: 'C5: the tool must expose centroid_state — that is the surface the model
           and the operator actually call. ...'

## Two of my own assertions were wrong first

Recorded because both are the failure mode this branch keeps hunting:

- `assert.equal(s.centroid, s.centroid, ...)` — a tautology; can never fail.
- `assert.equal(typeof x !== 'undefined', 'boolean', ...)` — compares `true` to
  the string `'boolean'`; fails for a reason unrelated to its subject.

Replaced with `assert.ok('last_recall' in healthy, ...)`, which states the actual
precondition. A criterion that cannot fail, or that fails for the wrong reason,
is worse than no criterion — it consumes attention and proves nothing.

Gates: npm test 301/301 (0 skipped), c5 10/10, npm run check 12/12.
2026-11-05 11:58:58 +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
bdd91234a5 test(memory): pin that a NON-ZERO drop counter survives to both surfaces (D2)
L2 asserts the three D2 counters are printed when every one of them is zero. That
proves the format string mentions them; it does not prove a real value makes it
through. Two things sit between the counter and the operator:

  `segmentRecallBudget` slices the hits array — and `slice` does not copy
  non-enumerable properties, which is what all the counters are;

  `lastRecall` is assembled separately from the log line.

So "correct in the returned array" and "visible to an operator" are different
claims, and only the second one is what D2 is about.

## Fixture

Three blocks on separate axes, query mostly on axis 0, default `minScore`:

    block0 cosine ~0.95   -> hit
    block1 cosine ~0.30   -> skippedScore
    block2 cosine 0       -> skippedNoEvidence

Measured through the real plugin path (apply + commit + contextForStep):

    log : ... skipped=0 skippedDim=0 skippedScore=1 skippedNoEvidence=1 truncated=0 err=- ...
    last: {"mode":"hybrid","skipped_score":1,"skipped_no_evidence":1,"truncated":0,...}

All three destinations are non-empty at once, so the criterion cannot pass by
accident of an empty fixture.

## Mutation

Removing the three fields from the log line turns both criteria red:

    not ok 2 - L2: debugLog=true emits one line covering recall, budget, degradation and injection
    not ok 4 - L2b: non-zero drop counters appear in the log and in last_recall

L2 alone would already catch that; L2b is what catches the other half — a counter
that survives the log formatting but dies in the slice, or lands in one surface
and not the other.

Gates: npm test 299/299 (0 skipped).
2026-11-05 11:49:01 +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
68775d327f fix(packaging): ship lib/embed/, without which the published tarball cannot load
`package.json` declared

    "files": ["lib/*.js", "lib/loop.js", "lib/loop/*.js", "lib/types/*.d.ts"]

`lib/*.js` matches ONE level. `lib/embed/http.js` therefore never entered the
tarball, while `lib/index.js:35` imports it:

    import { createHttpEmbedder } from './embed/http.js'

## How this survived

Every test in this repo runs from the source tree, where the file obviously
exists. `npm pack --dry-run` printed "10 files" — a plausible number nobody had
reason to read through. The defect is only reachable from the one path that
ships: installing the tarball.

Measured, extracting the tarball and importing it:

    IMPORT FAILED: ERR_MODULE_NOT_FOUND
    Cannot find module '.../package/lib/embed/http.js'
      imported from '.../package/lib/index.js'

After the fix, the same command:

    IMPORT OK, exports: StableMem,apply,default,defaultPath,inject,instanceKey,lastUserText,name

## Fix

`lib/embed/*.js` added to the allowlist. The tarball goes from 10 to 11 files
(86.1 kB) and `lib/embed/http.js` is in it.

## Criterion

`test/packaging.test.js`:

1. every `.js` under `lib/` must be matched by at least one `files` pattern.
   The patterns compile to regexes the way npm treats them (`*` does not cross
   `/`), so the criterion needs no hand-maintained file list and cannot drift.
2. every relative import in `lib/index.js` must resolve on disk — otherwise
   criterion 1 could pass vacuously for a file that does not exist.

Reverting `files` to the old list turns criterion 1 red:

    not ok 1 - packaging: every file under lib/ is covered by package.json "files"
    these files live under lib/ but no "files" pattern reaches them ...
      lib/embed/http.js

## Why it belongs in this branch

V6 made the embedder assembly load-bearing (two instances, one per side). A
release that cannot import `lib/embed/http.js` has no embedder at all, so the
distinction V6 introduced would never reach a user. The bug predates this
branch; its blast radius is the shipped artifact.

Gates: npm test 294/294 (0 skipped), npm run check 12/12, npm pack 11 files.
2026-11-05 11:38:56 +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
28e18d0258 test(memory): bound what P1's green does and does not establish (P1 follow-up)
Two discipline holes left after 1309afe / 09be4de, both closed here.

1. The boundary was unstated. A one-hot keyword-family fixture proves the
   PLUMBING — that the computed query vector reaches scoring — and says nothing
   about retrieval quality. Left implicit, a green P1 invites the reading
   "semantic recall works". The measured counter-evidence is now in the file:
   on the real chinese-clip space a true hit scored 0.8345 while an unrelated
   block scored 0.8598, so absolute scores are not calibrated and no threshold
   may be read off them. That is a known unresolved problem outside P1's scope.

2. `dsh` missing used to `t.skip`. A skip keeps `npm test` green while the only
   end-to-end evidence for P1 never runs — the same shape as a criterion that is
   committed but never speaks. It now fails, and says why.

No behavioural change: 272/272 tests, 12/12 checkpoints, 0 skipped on a box
where `dsh` is present. Verified the mutation discipline still bites — deleting
`'hybrid'` from ORDINARY_DEGRADATIONS turns L10, L11, L10† and L11† red.
2026-11-05 01:22:57 +08:00
09be4de0eb test(memory): pin that ORDINARY_DEGRADATIONS decides the log LEVEL (P1 follow-up)
P1 registered two healthy modes in `ORDINARY_DEGRADATIONS` — `hybrid` (the wired
success path) and `no_vector_space` (a space with nothing comparable to score) —
and the commit message claimed that meant "a healthy wired recall does not log a
warning every turn". Nothing tested it. Measured on 1309afe:

    M-a  delete 'hybrid'            → # tests 268  # pass 268  # fail 0
    M-b  delete 'no_vector_space'   → # tests 268  # pass 268  # fail 0
    M-ab delete both                → # tests 268  # pass 268  # fail 0

while the behaviour really broke (real apply(), debugLog=false):

    mutated hybrid recall → lines=[['warn', '... degraded=hybrid ... inject=138ch']]
    released hybrid recall → lines=[]

A false fault alarm on the happiest path, every single turn, and the suite green.

Why the existing tests could not see it: A3d/D3b assert the LABEL
(`hits.degraded === 'hybrid'`). Removing a member from the Set does not change any
label — it changes only whether `logRecallTurn` treats that label as a fault. The
defect lived exactly in the gap between "the label is right" and "the level is
right", and `grep ORDINARY_DEGRADATIONS test/` was empty. A claim in a commit
message with nothing behind it is the same failure mode as a guard that cannot
fail.

What this commit adds (test/memory-log.test.js only — lib/ untouched)

  L10    one group, three outcomes at the SAME verbosity (debugLog: false):
         `hybrid`               → zero lines
         `no_vector_space`      → zero lines
         `embed_error`          → exactly one line, type 'warn'   ← the control
         Each case asserts its own PRECONDITION first (the run really was
         `hybrid` / `no_vector_space`, and something was really injected), so a
         silent log cannot be confused with a build that never reached the route.
         Without the third case, "silent" would also be satisfied by a build that
         logs nothing at all — which is the original bug the log channel exists
         for (L4).
  L10†   real mutation: 'hybrid' removed from the Set → L10.1 red
  L11†   real mutation: 'no_vector_space' removed → L10.2 red
         Both via `withMutatedIndex` (copy the package, rewrite ONE anchor,
         `count === 1` self-check, import fresh). The mutant runs the SAME
         `assertLevelContract` body the released build passes — a criterion copied
         for the mutant can drift, a shared one cannot. The mutation then asserts
         the throw is `ERR_ASSERTION` AND that the message starts with the specific
         sub-assertion name, so an import failure cannot make it green for the
         wrong reason.
  L11    measured behaviour, pinned so nobody "fixes" it later: a fused recall
         whose candidates were ALL refused by `minScore` still reports `hybrid`
         with zero hits, and is still silent. `no_vector_space` is returned at
         exactly one place (`recall.js`, `vectorScores.size === 0`) and means
         "there was nothing to score" — never "everything was filtered". Collapsing
         the two would hide a threshold set too high behind a "fresh space" story.

Two fixture pitfalls found while writing this, both recorded in the test because
each one silently disarmed its own case:

  · `commit` embeds when an embedder is mounted, so the `no_vector_space` case has
    to write its block BEFORE the embedder appears. The first attempt mounted the
    embedder first, the space filled up, and the label came back `hybrid` — the
    assertion then failed for its own precondition rather than for the subject.
  · `StableMem.recall()` forwards only `topK`/`signal`, so a `minScore` passed
    there never reaches the scorer and L11's precondition would have passed on an
    unchanged result. It drives `recallBlocksHybrid` directly instead.

Not asserted, deliberately: `'no_embedder'`, `'aborted'` and `'vector_ready_unused'`
are also in the Set, but their silence is already carried by L1/L5 (no embedder)
and W11e/W12 elsewhere. Adding them here would inflate the group without covering a
new decision.

旁证 (not acceptance): npm test 272/272, npm run check 12/12, npm pack --dry-run
10 files / 75.3 kB. docs/ and README.md untouched.
2026-11-05 00:51:17 +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
c1f7e06b22 test(memory): 补 377e02c 的判据假绿 —— multiCarrier 名不符实,真多块零覆盖
`multiCarrier(texts, convention)` 名叫"多块",实际 `content: [{ type: 'text',
text: joined }]` 恒为 1 个 block。所以 S2a/S2b 读起来是"多块路径已覆盖",
实际一条都没走多块分支:

  M1 在 `const rendered = ...` 前插 `if (blocks.length > 1) return null`
     (即 V2 那个错误实现)→ 22 条判据 pass 20 / fail 0,全绿。

这正是上一轮 375→377e02c 留下的假绿:不是缺测试,是 helper 名字把人骗了。
名字夸大覆盖范围比没有测试更毒,因为它读起来像有。

改法(只动 test,lib/loop.js 零改动,sha256 f28c5564… 前后一致):

- `multiCarrier` → `singleBlockCarrier`,实现不动,JSDoc 写明
  "产出:1 个 text block 承载 N 个 section(lib/index.js:932 的真实形状)"
  并记下它曾叫 multiCarrier 这段历史。
- 新增 `trueMultiCarrier(texts, kind)`:N 个 text block,每个 block 一个
  section,rendered = texts.join('')(glued)。注释写明真多块没有 spaced 变体。
- S2a/S2b → singleBlockCarrier;S3 标题改成 "single content block carrying N
  sections != those sections …" 并改用 singleBlockCarrier。
- S2c/S2d → trueMultiCarrier(['a','b']),断言不变。

变异自证(两条都咬住,且撤净后 sha256 回到基线):
  M1 `if (blocks.length > 1) return null`   → S2c 红("got 1 message(s)")
  M2 删掉 glued 分支(只剩 `rendered !== spaced`)→ S2b 红("got 1")

对 5c6aaf5 的干净 archive 跑本判据:tests 22 / pass 10 / fail 12,
其中 S2c("got 1 message(s)")与 S2d("got 2")如实为红。
2026-11-04 18:32:16 +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
f1b74a35ef feat(memory): ship the profile overlay that mounts the replacement loop
6d9ad62 added `lib/loop.js` — a subclass of the harness `AgentLoop` that moves
per-turn recall out of conversation history and into the system prompt. That
class was never instantiated by anything: the commit proved the pure
`promoteSnapshotSections` function, not that a replacement loop can actually be
mounted. This commit adds the two pieces the mount needs.

`cordis.patch.yml` is the overlay that does it, and it is the only artefact that
proves the swap is possible:

- `- id: agent-loop / disabled: true` is mandatory and is addressed by ID, not
  by name. `AgentLoop`'s constructor ends with
  `ctx.effect(() => ctx.agents.setFactory(this))`
  (dsh-agent-loop/lib/index.js:1590) and `setFactory` throws when a factory is
  already registered (dsh-agent/lib/index.js:429-437). Leaving the base row
  enabled fails startup with "an agent factory is already registered".
- The inserted row reuses the package itself under the `dsh-memory/loop`
  subpath, with no `config` block: `HologramAgentLoop` declares no `static
  Config`, so it inherits the parent schema and the profile's own `agent-loop`
  config rows keep working through the inherited constructor.

`package.json` gains that subpath export plus `lib/loop.js` in `files`. Without
the export the overlay resolves to nothing and the profile cannot boot — the
two changes only work together, which is why they land in one commit.

Measured on this machine with the overlay applied to a headless profile
(`~/.dsh/profiles/headless/cordis.patch.yml`), dsh 0.2.1-rc.2:

- `dsh --profile headless "回答两个字:收到了"` -> exit 0, stdout `收到了`;
  `--dump-config` shows `- id: agent-loop` with `disabled: true` alongside
  `- id: hologram-agent-loop, name: dsh-memory/loop`.
- A tool round trip returns the tool's output: asking for
  `echo TOOL_OK_42` via bash yields `TOOL_OK_42`.
- SIGINT during a long task exits in 1s with code 130, stdout empty.
- The web profile's patch layer is still `[]` — untouched.

npm test: 247 pass, 0 fail.
2026-11-04 17:07:48 +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
771a053eea docs(CL-1F): restore A1 semantics and cite V7 logs for G1
A1: revert to the 06155d4 wording (recallBlocks stays synchronous, a new
async recallBlocksHybrid is added). The previous basis claimed the async
pre-step handler justified making recallBlocks async, which the code
contradicts: lib/recall.js:229 exports a sync recallBlocks with zero awaits,
and lib/recall.js:613 is the async wrapper.

G1: replace the stale count with the readings already present in the V7
evidence logs (/tmp/v7-order-20261101-173334-t1..t3.log = npm test,
225/225/0; c1..c3.log = npm run check, 12/12), noting that phase A is a
subset of the whole-suite run. No new test/check run was performed.
2026-11-01 17:52:52 +08:00
7fc3d46b9a docs(CL-1): revert V2 documentation changes 2026-11-01 17:41:52 +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
8a87c973d2 test(memory-b): add self-built tier-3 flip case 2026-11-01 17:08:54 +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
b7eff5db66 test(memory-b): strengthen C3 recall and corruption mutants 2026-11-01 12:39:53 +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
8bdd9f5ce0 test(memory-b): make F8 mutants bite the positive criteria 2026-11-01 11:11:37 +08:00
96adb172eb test(memory-b): add F8 checkpoint for width validation 2026-11-01 11:01:44 +08:00
b5d3f6ec73 feat(memory-b): add F7 fingerprint space stats 2026-11-01 10:50:46 +08:00
f62b826c64 test(memory-b): 补 unknownSpace / dimensionMismatch 的文案判据(四态齐)
你的复核成立:renderUnavailableTail 每态一个 clauses.push,但只有
spaceMismatch(D5b)与 noVector(D5c)有判据。删掉另两段,全套仍绿 ——
它们在渲染层是装饰,与恒 0 的 skipped_dim 同型。

我复现时先得到「各红 1 条」,查下去发现那是副本缺 README 导致
「README states no counts」那条报 ENOENT 的噪音,不是判据咬住。
去掉噪音后确认:你的结论正确,两态确实全绿。

补四条(每态一条判据 + 一条抑制变异)
  D5e  unknownSpace:断言「1 without a space label」,且断言文本里
       **不含** browse / exact text —— 该态走语义路,借用 noVector 的
       「可浏览」说法会误导(这是 F6 那条 browse≠recall 口径的第三次复用)
  D5f  dimensionMismatch:断言「1 whose width does not match this provider」
       **且**指向「fix the config」这一补救,且不含 browse
  D5e† 删 unknownSpace 的 clauses.push ⇒ 必须红
  D5f† 删 dimensionMismatch 的 clauses.push ⇒ 必须红

真变异实测(删整段 clauses.push)
  删 unknownSpace   → not ok D5e、not ok D5e†   (2 红)
  删 dimensionMismatch → not ok D5f、not ok D5f†(2 红)
每条变异同时咬住「本体判据」与「自己的 suppression 测试」,不是只红一条。

两处写作教训
- D5f 的构造必须走 raw putBlock:F8 正确地拒绝显式传错宽度,
  所以「库里有窄向量行」现实中只来自历史数据或修复脚本
- D5e†/D5f† 初版只 new Context() 没装 systemPrompt/tools 服务,
  dispose 时 "Cannot read properties of undefined (reading 'context')"
  —— apply() 的 inject 需要它们,已补

现状:四态 noVector / unknownSpace / spaceMismatch / dimensionMismatch
在**渲染层**各有判据与抑制变异,不再有装饰态。

验证
- npm test 166/166(162 + D5e/D5f/D5e†/D5f†)
- npm run check 10/10
- 干净副本实数见后续消息
2026-10-30 17:26:16 +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
48c52497c2 feat(memory-b): F6 三段口径固化成判据 + A1b 降级记账
裁定:F6 选 (a),措辞改写,不留互斥读法
原措辞「仍可按文本调出、不参与向量召回」读起来像互斥。实际是三层:
  「按文本调出」= store 级无空间浏览,**不是生产召回路径**。

不改 F2 去迁就 F6 字面自洽的理由:F2 是 fusion.go:495 同款安全缺陷修复,
优先级高于 F6 的措辞。所以 F6 改成三段并列,每段一条断言,
**同一个用例内全断言**:

  数据层:指纹变更后旧块仍在库中 —— 块数不减、按 fp 分组两行都在
  浏览层:store 级不带空间参数的文本查询仍能取到旧块
  召回层:带 fingerprint 的生产召回路径必须把旧块过滤掉(F2 承担)

为什么必须一个用例:只断言「过滤掉了」会漏掉数据被误删
(删库在召回层看起来就是「过滤正确」);只断言「能调出」会把 F2 挡回去。

F6 判据 3 条
  F6          三段全断言 + 反向对照(当前空间块两层都能取到,
              防止断言因为「什么都找不到」而假成立)
  F6†data     变异:putBlock 顺手 purge 其它空间的行走道 ⇒ 数据层断言红
  F6†recall   变异:去掉空间过滤器 ⇒ 召回层断言红
两条变异各自只咬自己那一段,与 F6†data/†recall 的分工一致。

★ F6†data 的变异写法踩了三次坑(据实记)
  1. 只写 form(没真改代码)—— 那不是变异,是自测
  2. 注入 SQL 里的转义引号把测试文件写成 SyntaxError
  3. 最终用 String.fromCharCode(39) 拼引号 + 数组 join 构造注入文本,
     避开嵌套模板字符串的转义地狱

裁定 2:A1b 降级要记账
A1b 原先逐字节锁死 recallBlocks 源码,F2 合法地要改它,已降级为
「不传 fingerprint 时行为不变」。但降级后的 A1b-behaviour 只跑 golden
**前 5 个** case,变异 A(非精确带分数微调)下它可能是绿的。
已在判据注释写明:**结论不由 A1b 单独给出,由 A2 兜**
(A2 走全部 45 例、比精确分数);A1b-behaviour 只是烟测,不是权威。

同步进 memo:docs/dev-notes-mutation-criteria.md 增第 5 条
「判据降级时,覆盖面必须一起交代」—— 这不是失效模式,是记账纪律。
自查问句:这条判据降级后,原本由它兜住的结论现在由谁兜?
答案必须是一个具体的、覆盖更广的判据名。

验证
- npm test 131/131、npm run check 10/10
- 干净副本(git archive + link-dsh)实数见后续消息
2026-10-30 15:39:03 +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
e2c2bb41b7 docs: 删掉 README「nothing to install」假声明,改正 peer 叙述
d144284 补了「Prerequisite: link the harness packages first」章节,但留下两处
与新章节自相矛盾的话,这一提交收掉:

1. README §Install 的安装注释 `npm install  # nothing to install; dsh packages
   are peers` —— 直接删。实测它不成立:干净副本里 npm install 只拉到 cordis
   (四个 peer 里唯一非 optional 的)加它的传递依赖,dsh-llm /
   dsh-system-prompt / dsh-tools 三个仍然缺;不 install 更彻底,四个全缺。
   改成说明 peer 归属 + 指向前置章节的链接。
2. 新章节开头 `This repository has **no runtime dependencies**` —— 同样不成立
   (lib/index.js 运行时 import 三个 @deepseek-ai 包)。改成「不以本插件自身
   依赖的形式拉取任何东西」,把 peer 归属说清,并写明 npm install 只解决
   cordis 一个。
3. 症状段补一句 npm run check 同样红(实测 exit=1,checkpoints.js 在 import
   阶段就 ERR_MODULE_NOT_FOUND),原文只提了 npm test。
4. scripts/link-dsh.sh 头部注释里的 `zero runtime dependencies` 一并改成同义
   但不撒谎的说法。

验证(自己跑,不转述)
- 干净副本 git archive HEAD + 本补丁,不 link:
    not ok loader / phase-b-g / phase-b-h / plugin
    # tests 34  # pass 30  # fail 4
    npm run check exit=1(Cannot find package '@deepseek-ai/cordis')
- 同一副本 bash scripts/link-dsh.sh 之后:
    # tests 120  # pass 120  # fail 0
    npm run check exit=0,10 条 ✔
- npm install 单独一步(不 link)也复现:四文件 ERR_MODULE_NOT_FOUND,
  缺的是 dsh-system-prompt / dsh-llm(cordis 已装)
- README 全文再无「nothing to install / no runtime dependencies / zero
  runtime dependencies」类断言(grep 零命中)
- README 计数禁令仍绿,无 .orig/.rej/.bak 入库

更正一处旧结论:交接说「不 link 时 4 个文件 ERR_MODULE_NOT_FOUND」= 少 4 个
用例,不对。那四个文件在 import 阶段就死,用例根本没注册,所以 npm test 汇总
是 34 tests,不是 116/120。已在 README 里写成「a handful of tests rather than
the full suite」,避免再误导。
2026-10-30 15:07:45 +08:00
d1442848c4 docs: README 补前置准备(干净 clone 直接跑 npm test 会红 4 个文件)
缺口(评审复现,我独立复现确认)
git archive HEAD 到空目录、原样 npm test:
  # tests 34  # pass 30  # fail 4
  not ok test/loader.test.js / phase-b-g.test.js / phase-b-h.test.js / plugin.test.js
根因:Cannot find package '@deepseek-ai/cordis'。
而 README 全文对 link-dsh.sh / node_modules / 前置准备零命中 ——
正文只写「npm test 是 how many 的权威」,默认读者已经跑过 link-dsh.sh,
而这个前提谁都没写。

这与本轮刚修的那个缺口同族:判据/文档声称的能力,在没有守卫的地方悄悄不成立。
(刚删掉 README 计数,理由是「没守卫就会漂」;「npm test 是权威」这句话
  同样没有守卫 —— 它默认了一个没人写下来的前置。)

修法(按裁定选 ①,不用 pretest 钩子)
README §Verify 开头新增「Prerequisite: link the harness packages first」:
- 说清本仓零运行时依赖,@deepseek-ai/* 全部是 peer,
  必须来自机器上已装的 dsh(vendoring 会与它要运行的 harness 漂开)
- 给命令 bash scripts/link-dsh.sh
- 明确写出跳过它的症状:四个文件 ERR_MODULE_NOT_FOUND
  (loader / plugin / guard / http 四套,与实测逐一对齐),
  并说明这是缺前置的正常形态,不是树坏了
- 补非默认安装根的可选参数

按裁定的第二条不加判据去测「干净 clone 能跑测试」(成本高于收益),写清楚就够。

顺带一处:README 的计数禁令只匹配数字形态(119 tests / 10 checkpoints /
10/10 passed),新段落用了英文「four files」,不被误报 ——
在守卫里加注释说明这个 scope,以及为什么不去匹配文字形态
(「四个文件报 ERR_MODULE_NOT_FOUND」是合法的散文,再往下写就变成小解析器)。

验证
- 干净 clone 复现:无链接 4 红;bash scripts/link-dsh.sh 之后 120/120、check 10/10
- README 描述的失败文件清单与实测逐一一致
- npm test 连跑 5 次 120/120
- npm run check 3 次,exit=0、10 条全绿
- README 计数禁令仍绿(未误报新段落)
- 无 .orig / .rej 残留,未 amend / 未 force push
2026-10-30 15:02:38 +08:00
0e65dfee64 fix(check): checkpoint 全废时 runner 仍报通过 + 补回丢失的覆盖
缺口(评审复现,我独立复现确认)
上一条替换旧 README 判据时,把它的第二条断言一起带走了。
旧 test 同时守两件事:
  1) README 计数与实现一致      已判定不值得守,删
  2) checkpoints.js 至少注册一条 值得守,跟着 1 一起消失
grep 全仓:没有任何东西守 2。

实测后果(很安静)
把 helper 换成空操作、调用点全保留:
  npm run check  ->  0/0 checkpoints passed, exit=0
  npm test       ->  120 条全绿
即 checkpoint 全废、runner 报通过、测试一条不红。

修法:守结果,不守成因
scripts/checkpoints.js 结尾(if (failed > 0) 之前):
  if (results.length === 0) {
    console.error('no checkpoints were registered ...')
    process.exitCode = 1
  }
放 runner 而不是补回静态计数的理由:静态计数只能抓 helper 改名;
results.length === 0 抓的是结果,改名、被 stub、条件跳过都会红。

但 process.exit(0) 能绕过任何脚本内检查,所以另加一条外部断言
(test/phase-b-g-static.test.js)三层:
  - declared > 0                    helper 没被改名
  - 源码含 if (results.length === 0)
  - 该守卫确实设置 process.exitCode = 1
分工:runner 守结果,测试守 runner 还带着那道守卫。

实测
  0 条注册(stub)    -> no checkpoints were registered, exit=1
  条件跳过            -> 同上, exit=1
  正常态              -> 10/10 passed, exit=0
  变异:删 runner 守卫 -> not ok 6
  变异:helper 全改名  -> not ok 6

docs/dev-notes-mutation-criteria.md 增第 4 类
判据被替换时,旧判据的覆盖静默消失
- 形态:一条 test 守两件事
- 实测后果:0/0 passed / exit 0 / 120 全绿
- 守结果不守成因的四类成因对照表(含 process.exit(0) 是拦不住的那格)
- 自查问句:这条判据除了名字里那件事,还顺手守了什么
- 探针经验:不要用注释掉调用去验零注册,会变成 SyntaxError

验证
- npm test 120/120、npm run check 10/10
- npm test 3 次、npm run check 3 次(带 exit code 核对)全绿
- 无 .orig / .rej / /tmp/mut-* 残留
- 未 amend / 未 force push
2026-10-30 14:44:09 +08:00
fc5fe11590 docs: 去掉 README 计数并在判据侧禁止它 + 变异判据方法论 dev-notes
1. README 计数去掉(不留守卫)
去掉「119 tests」与「10 checkpoints / 10/10 checkpoints passed」。
理由按裁定:一个没有守卫、被手改过五次的数字是负资产,
它给人的确定感是假的;加守卫要翻倍套件时间,不值。

守卫反过来断言「README 不得出现计数」:
  /\b\d+\s+tests\b/i
  /\b\d+\s+checkpoints\b/i
  /\b\d+\s*\/\s*\d+\s+checkpoints passed/i
三种写法实测都能抓住(120 tests / 12 checkpoints / 12/12 passed)。

顺带处理一处同类:原文「(6 tests)」是手抄的测试数,删;
「12 blocks × 15 queries = 45 cases」是 fixture 自身内容量,也删并改为
指向 fixture 文件 —— 由文件决定的东西不该在文档里再写一份。
(README 现在的口径:表格说「验什么」,`npm test` / `npm run check` 说「多少」。)

2. docs/dev-notes-mutation-criteria.md(新)
把三轮踩出来的方法论落成仓库文档,README 的 Verify 段加指针。
三类失效模式,每条都给「形态 / 实测证据 / 正确形态 / 自查问句」:

  ① 自测型假变异(犯四次:W11† / W11d†a / W11d†b / W12†)
     变异在 test/ 里自己拼替身,一行 lib/ 没碰 ⇒ 真回退下保持绿
     实测:M-a / M-b 下 W11d†a / W11d†b 均绿,只有 W11d 红

  ② 清单写死(W11f)
     手写 MODES 漏掉新 case;future_mode_a/b 测的是「掉进 default 的」,
     永远测不到「有人新写了一个 case」
     实测:植入 brand_new_mode(零命中时撒谎)→ 写死版全绿,扫描版红
     并记下边界:自动推导不能替代「提取本身也要被守卫」,
     所以还需要 declared.length >= 4 + W11f-a(guards the guard)

  ③ 变异共享状态并发撞车(我自己引入的)
     就地改写 lib/*.js,而 node --test 并行跑文件 ⇒ 别的文件读到中间态
     实测:约 1/30 偶发失败,症状像 flaky 断言;A1b / G2b / G5 这类
     读源码的判据会读到被改坏的中间态
     修法:在 lib/ 副本上变异(复制 lib + package.json + 软链 node_modules),
     finally 清理,锚点失配显式失败

文末给通用自查清单 9 条 + 一句话记住三条。

验证
- npm test 119/119、npm run check 10/10
- 连跑 6 次全量 0 失败
- 无 .orig / .rej / /tmp/mut-* 残留
- 未 amend / 未 force push,199553f 之后是新提交
2026-10-30 14:36:41 +08:00
199553f91a test(memory-b): W11f 模式清单改为从源码 case 字面量推导
接你备好的两个加固 patch(w11f-hardening-test / -README),
应用后 md5 与期望一致,lib/ 零改动:
  test/phase-b-h.test.js  655fd63d8c18d88a7b7885868654fb80
  README.md               dcf50c6138a8357ca21363f09a104597
  lib/recall.js           c8fdffd25d5340291adf6cb26e1741f(未变)

盲区(我在 d972a2d 上独立复现了)
在 lib/recall.js 里植入一个会撒谎的新分支:
    case 'brand_new_mode':
      return '[Memory] New mode degraded; matches below may be incomplete.'
旧版 W11f / W11f† / W11f†b 三条**全绿**。
根因:MODES 是手写清单,新 case 不在里面。
而 future_mode_a / future_mode_b 那两个名字有误导性 ——
它们测的是「掉进 default 的未知模式」,永远测不到「有人新写了一个 case」。
而正常演进恰恰就是新写 case,那时 default: 与手写清单**两头都管不到**。

加固(补丁采用)
- 模式清单从源码 case '<literal>' 扫出来,不再手写
- declared.length >= 4 + 三个必需 mode 的存在性断言:
  提取失效就响亮失败,而不是迭代空列表照样绿
- 新增 W11f-a(guards the guard):用变异往源码塞一个新 case,
  断言扫描能看见 —— 因为「扫描器坏掉」是这套判据唯一能静默失效的方式
- 追加 'mode_with_no_branch_at_all' 覆盖「无 case、落 default」那一态

同一植入下新旧对比(我实测)
  旧版(写死 MODES) → # pass 3 / # fail 0,漏
  新版(源码扫描)   → not ok W11f
    'brand_new_mode + zero hits must not promise entries below;
     got: [Memory] New mode degraded; matches below may be incomplete.'

另做了两个更狠的探针,确认它不会静默失效:
- 函数改名导致导入失败 → 整个文件响亮失败(不是静默绿)
- 保留函数名但把 case '<literal>' 全改成常量引用(语法合法仍可导入)
  → not ok W11f: 'expected at least 4 case literals in degradationFound,
     found 0: ' —— 正是设计意图

也没变松:W11f†(删 aborted 分叉)/ W11f†b(删 default 分叉)仍各自只咬自己那半。

验证
- npm test 119/119(+W11f-a)
- npm run check 10/10
- 连跑 8 次全量 0 失败
- 变异 runner 零残留(/tmp/mut-?????? = 0)
- 未 amend / 未 force push,d972a2d 之后是新提交
2026-10-30 14:13:39 +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
e08c0bc569 fix(memory-b): G5 白名单恒假 —— 加合法 adapter 会误炸
你抓的这条是真 bug,属"绿着但错"。

根因
test/phase-b-g-static.test.js 里
  const ADAPTER = 'lib/embed/'
  if (rel.startsWith(ADAPTER.slice(4))) continue   // slice(4) === 'embed/'
而 rel 是 'lib/embed/http.js' ⇒ startsWith('embed/') 恒 false
⇒ continue 永不执行 ⇒ 白名单等于不存在。

它一直绿,只是因为 lib/embed/ 目录还没建,空跑。
H 组一落地,这个守卫就会对合法的 adapter 自己报红
(fetch( / Authorization / Bearer / https://),
然后最省事的下一步就是把它删掉或放宽 —— 误炸的守卫一定会被关掉。

修法
  if (rel.startsWith(ADAPTER)) continue
并在注释里写清这个 bug 的形态,防止再被"优化"回去。

实测复现(按你给的场景,在你的工作区之外做的)
1. 只放一个合法 lib/embed/http.js(带 fetch + Authorization + https://)
   修前:not ok 2 - G5  (报 lib/embed/http.js: fetch( / Authorization / Bearer / https://)
   修后:ok 2 - G5
2. 往 lib/store.js 塞 fetch('https://x/')
   修后:not ok 2 - G5,且只点名 lib/store.js

补四条变异(你要求的三条 + G2†)
你指出 G 组 5 条守卫一条变异都没有 —— 确实是疏漏,
上面那个 bug 正是没有变异的直接后果。已补:

  G5†  把 startsWith(ADAPTER) 改回 startsWith(ADAPTER.slice(4))
       实测:放合法 adapter 后 → not ok 2 - G5(红);
             改回正确写法 → 恢复绿
  G5b† lib/store.js 插 fetch('https://x/')
       实测:not ok 2 - G5,报 'lib/store.js: fetch(' 与 'lib/store.js: https://',
             且不点名 lib/embed/
  G2b† lib/recall.js 加 import 'onnxruntime-node'
       实测:模块已无法加载(LOAD FAILS: ERR_MODULE_NOT_FOUND),
             而静态守卫仍独立报出 'lib/recall.js: onnxruntime-node'
             —— 这正是上一轮把静态判据拆出独立文件的收益
  G2†  无 embedder 路径真发一次 fetch
       实测:not ok 1 - G2,报 "no embedder must mean zero network calls; saw 1"

G5†/G5b†/G2b† 的实现方式说明
它们不修改磁盘上的 lib/(判据不该改它正在验证的树),
而是把同一套检测逻辑参数化后跑合成文件表,断言"规则如所写会抓到这个"。
G5† 额外把有 bug 的前缀也实现了一遍,证明它会放过合法 adapter ——
即这次 bug 的形成机制。另三条(G5b†/G2b†/G2†)同时做了真实代码变异,
红→绿的原文见上。

验证
- npm test 49/49(46 + G5†/G5b†/G2b† 三条)
- npm run check 9/9
- 提交前连跑 3 次 test + 3 次 check,6 次全绿

票②(两表 fingerprint 字符串相等)与票③(D7 fail-closed)
跟 F 组一起落。G 组修完,H 组可以开工。
2026-10-30 10:12:40 +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
885e0b66a3 test(memory): 补 G6/G7 两条判据,把真实 batch 教训写进 README
按要求补两条,均在补之前先跑红。recall.js 本轮不动(零改动已核)。

G6 —— 白名单判据用真实 kind,不测自造名字
上一版只测了自己猜的 `runtime-context`。现在参数化覆盖 8 个真实
注入 kind,每一条都是从已安装包里查出来的字面量:

  runtime-context      dsh-agent-loop/lib/types/runtime-context.d.ts:12
  skill-catalog        dsh-tool-skill/lib/index.js:984, :1008
  skill-invocation     dsh-tool-skill/lib/index.js:913
  schedule             dsh-schedule/lib/index.js:1594
  agent-message        dsh-subagent/lib/index.js:446
  user-question-reply  dsh-user-questions/lib/index.js:563
  time-context         dsh-time-context/lib/index.js:237
  agent-instructions   dsh-agent-instructions/lib/index.js:904

反向变异:把 HUMAN_PROMPT_KINDS 放开成「role 是 user 就收」⇒
必须对全部 8 个 kind 都失败(不只是其中几个)。

G7 —— kind === undefined 必须返回 ''
实现本来就是 fail-closed,但按要求用断言钉死,不依赖推断。
覆盖 5 种形态:source 缺失 / source 为 null / source.kind 缺失 /
kind 非字符串 / kind 为空串。
变异:去掉 typeof kind !== 'string' 守卫 ⇒ G7 红。

★ 关于 G7 的可达性,查证后如实记录:当前 harness 下这个状态
不可达。assertMessageEventShape 要求 source.kind 为非空字符串,
且 append 路径(dsh-session/lib/index.js:1089)与 seed/restore 路径
(:1165)都跑它。所以 G7 是纵深防御(防未来 format 变更),
不是活 bug。已在 README 和测试注释里写明,不夸大成"修了个洞"。

实测变异验证:删掉 kind 守卫后 G6/G7 四条全部变红,恢复后全绿。

README 补:
- kind 对照表改为带真实来源行号的 8 行
- 新增「★ 教训:2 条消息的 batch 是假 batch」小节:V3b 之所以让
  这个 bug 漏过,正是因为它选了自己构造的 batch 形状;规则是
  凡测 agent/pre-step 的判据一律走 realBatch()
- V3b 保留(仍钉注入归因)但标 V3b⚠ 已知盲区
- 测试表列出 G6/G7 及其变异

顺带:realBatch() 从用例之间提到文件顶部共享位置,V3b 也改用它
(原先是手造单条消息)——否则"必须走 realBatch()"只是一句空话。

验证:npm test 33/33(原 29 + G6/G7 四条)、npm run check 9/9。
2026-10-30 09:59:17 +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
eac9f0fca6 docs: phase B 开工前决策建议 —— 异步化回归风险可结构性消除
回应四条裁定与一个交叉点。不含实现。

核心结论:草案 A1「recallBlocks 改 async」是我图省事的设计偷懒,
把「要拿向量」和「要打分」混在一个函数里。切分后风险归零:

  recallBlocks(store, query, opts)              保持同步,签名行为不变
    新增可选 opts.vectorScores = Map<blockId, number>
  recallBlocksHybrid(store, embedder, q, opts)  新的 async 包装

实测调用面:生产代码 3 行(lib/index.js:124/140/277),
测试代码 0 行(test/store.test.js 5 处 + scripts/checkpoints.js 4 处
全部继续调同步版)。已实测确认 test/plugin.test.js:119 那个
`contextForStep = () => ''` 的同步桩在 async 化后仍兼容(await 非
Promise 值即其本身),所以那条变异测试继续有效。

建议把 guard 组 A1 改成「recallBlocks 保持同步且实现不变」,
并新增 A3:hybrid 在 embedder 为 null 时返回值与同步版逐字段相同
(含 why 字符串)——切分后最大的新风险是两个入口漂移,A3 钉死它。

四条裁定核对
- §2.1 endpoint 规范化:建议固定四条(去尾斜杠 / host 小写 /
  省略默认端口 / 去空白),不做 query 重排与路径归一。
- §2.2 四态:可执行,我草案的三态漏了「向量化失败」,裁定并进来更准。
  新增 D7:stats 默认不解析向量 JSON(用 length(vector)),维度从
  graph_centroid 取。修正一处我原稿的说漏——HA 为算 Dimensions 必须
  逐行 decodeVector(block_recall.go:303/313),所以「维度统计」与
  「不解析」互斥,只能靠中心表绕开。
- §2.3 centroid 补 stats 列:已列为第一件事。
- §2.4 失败降级:同意,并建议 mode 三值而非 bool
  (no_embedder / embed_error / all_mismatched 对用户意义不同)。

§3 建议把 embedder 焊成单方法窄接口(embedText / info / close),
加 G5 源码 grep 守卫:除 lib/embed/http.js 外不得出现 http/endpoint/
fetch/Bearer 等 provider 专属标识——机器可查,比约定强。

§4 交叉点:建议投完整 phase B,按 §1 切分后 5.5-6.5 人天。
但建议你先确认一件事:是否有真实使用中的 dsh 会话在产生块?
若无,可先投 2.5 人天的中间态(向量写入 + 四态可见,不改召回主路径)。

§6 记录本轮两处自我修正。

参考仓未改动。
2026-10-30 09:52:06 +08:00
0dfa6dcb27 docs: phase B 判据与变异规则草案(待批,不含实现)
decision 1(http 转发、不背 ONNX)与 decision 3(跳过不报错但失配计数
必须暴露到工具面)已落实到判据;decision 2 未回,全文按草案状态。

开工前必须先定的 4 件事(各附我的建议与代价):

§0.1 ★ HA 的 http 指纹派生式漏了 endpoint
     http_embedder.go:99 只取 "http:"+model+":"+dim。
     换 endpoint(换机器/A-B 服务/换 region)而 model 同名同维时,
     指纹相同 ⇒ 静默混用两个向量空间。
     建议扩展为含 endpoint hash;代价是换 endpoint 触发一次全量重嵌。

§0.2 ★ 空 fingerprint 的带向量块会被静默放行
     block_recall.go:140 的跳过条件是三个 && ⇒ fp 为空时不跳过。
     phase A 的块全都无向量,phase B 回填后「无向量」与
     「有向量但指纹空」是两种不同状态,混在一起用户无法知道怎么修。
     建议三态归类 + fail-closed(no_vector / no_fingerprint / stale / active)。

§0.3 graph_centroid DDL 与 HA 不一致(我的疏忽)
     少了 stats 列,而 HA 靠 meta.Anomalous 决定要不要启用中心化
     (centroid_store.go:205-209)。现在表是空的,改列名零成本。

§0.4 embedding 调用失败是否一并进计数器(与 decision 3 同构,建议纳入)

判据 6 组共 34 条(A 前置重构 / F 指纹空间 / V 融合 / C 中心化 /
D 降级可见性 / H http provider / G 守卫),每条带变异。
变异规则汇总出 12 条必须能红的,其中 M-V1(精确串从布尔降成权重)
是 HA 真实踩过的 bug(0.300 输给 0.534)。

另外三项判断:
- 增加 golden fixture 固化 phase A 召回结果,作为「加向量没顺手改符号路」的守卫
- memory_blocks 要加 vector/fingerprint 两列,phase A 的 DDL 已发布 ⇒
  建议 phase B 第一件事补 PRAGMA user_version 迁移框架(现在 0→1 一步,
  一天成本;等 L2 落地再补就贵了)
- 工作量从原估 3-4 人天上修到 6-7 人天(原估漏了 schema 迁移、
  stablemem_stats 工具、异步化带来的全量测试改动)

不含任何实现。参考仓未改动。
2026-10-30 09:49:07 +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
36 changed files with 18721 additions and 20 deletions

8
.gitignore vendored
View File

@ -1 +1,9 @@
# pi session transcripts
.pi-sessions/
# plugin runtime data (created by defaultPath())
.dsh-memory/
# dev-only symlinks to the globally installed dsh packages.
# `scripts/link-dsh.sh` recreates them; the plugin itself has no dependencies.
node_modules/

784
README.md
View File

@ -1,33 +1,777 @@
# HologramAgent-DSH-Memory-Port
评估 HologramAgent 三层记忆系统移植为 dsh 插件的可行性(调研/评估,不产出实现)
把 HologramAgent 的记忆系统移植到 dsh 插件。本仓分两部分:
## 报告
1. **调研报告**(已交付)
- [`docs/feasibility.md`](docs/feasibility.md) — 三层记忆移植可行性评估(结论:**只能做子集**,L2+L3 可移植,L1 抓不住)
- [`docs/shadow-context.md`](docs/shadow-context.md) — 影子上下文方案核实(结论:路线② preset 可行,路线① 只是看着可行)
2. **MVP 阶段 A 实现**(本文件以下内容)— 1–2 天切片:块能进 dsh、能被召回、能进 prompt,**不引入 embedding**
→ [`docs/feasibility.md`](docs/feasibility.md)
---
## 结论摘要
# dsh-memory(阶段 A)
**只能做子集**(四选一中的第三档)。
HologramAgent-style **block memory** for [dsh](https://github.com/deepseek-ai/deepseek-harness),
as a dsh plugin. This is **phase A** of the MVP described in
[`docs/feasibility.md`](docs/feasibility.md) §7.1 — the smallest slice that proves
the whole chain works:
| 层 | 可移植性 | 瓶颈 |
```
commit a block → store it durably → recall it → get it into the model's prompt
```
Deliberately **not** included: embedding providers, vectors, the L1 eviction flow,
media blocks. Each is blocked or out of scope for a 1–2 day slice; see
[What this slice does not do](#what-this-slice-does-not-do).
---
## ★ Read this before touching injection
dsh has two injection hooks that look interchangeable and are not. Getting this
wrong silently corrupts the conversation log.
| hook | receives this turn's input? | durable side effect |
|---|---|---|
| L1 context 热上下文 | ≈20% | dsh 的上下文压缩是「摘要替换区间」,不是「按 query 相关性淘汰 + 迁移到冷层 + 按原时间戳迁回」;`CompactionTrigger` 接口拿不到 query,`MessageBase` 无时间戳 |
| L2 coldcontext 冷文档 | ≈80% | 存储/召回/注入都能落,缺「回收站式 `Peek→回灌→Remove`」 |
| L3 stablemem 图记忆 | ≈85% | 结构完全可映射(`node:sqlite` 实测可用),受限于无 embedding 服务 |
| `ctx.systemPrompt.context({ text })` | **No** — `AssembleContext` is `{scope, signal}` + a merged-in `agent` | appends a runtime-context snapshot |
| `ctx.on('agent/pre-step')` | **Yes** — payload carries `decision.messages` | **every message you return is appended as a durable `user/message`** |
**可整体搬运的资产**:`MemoryBlock` 值对象、确定性 ID 派生、仲裁/拒答/符号检索算法——这三块与宿主无关。
Evidence, verified in the installed harness (not inferred):
**推荐落地范围**:L2 + L3 子集,L1 交给 dsh 自己的 compaction。
工作量(含判据与实测)**36–53 人天**;压到 MVP(L3 + 工具 + 注入面)**19–25 人天**。
- `dsh-system-prompt/lib/types/index.d.ts:37-44` — `AssembleContext` has only `scope` and `signal`
(`agent` is added by `dsh-agent`'s declaration merging; still no messages).
- `dsh-agent-loop/lib/index.js:902-934` — `preStep()` claims the inbox, calls
`systemPrompt.assemble()`, then runs the `agent/pre-step` waterfall with those messages.
- `dsh-agent-loop/lib/index.js:1066-1073` — the loop then does
`session.append('user/message', message, { surfaceOp: 'append' })` **for each returned message**.
**最小第一步切片**(1–2 人天):插件骨架 + `Service` 注册 + `node:sqlite` 建 `memory_blocks`
+ FTS5 召回 + `agent/pre-step` 注入 + 一个最小工具,**全程不引入 embedding 依赖**。
详见报告 §7。
**Consequences, and how this plugin uses them:**
## 取证方式
- The **input-independent** index (a block count, "memory exists, here's the tool")
goes through `systemPrompt.context`. It cannot see the current question, so it
must not pretend to be a recall result.
- **Per-turn recall** goes through `agent/pre-step`, because that is the only hook
that sees the input. Recall is `prepend: true` so it runs before listeners that
may want the final word.
- Because pre-step output is **durable**, injected recall becomes part of the
conversation history. That is acceptable here (it is honest: the model saw it),
but it means this plugin is **not** a shadow-context mechanism. For that
distinction see [`docs/shadow-context.md`](docs/shadow-context.md) §1.3.
- HologramAgent 源码与 `docs/zh/` 设计文档:**全程只读,未做任何修改**
- dsh 接口:只读 `lib/types/*.d.ts`(权威接口),不从 `lib/*.js` 实现倒推
- 关键环境结论均有实测(`node:sqlite` + FTS5、`onnxruntime-node` 缺失、`libonnxruntime.so` 位置)
- 每条断言标注文件路径与符号名;仅三处标注「推断」:`turn`≈`FrameTS` 映射、`Intl.Segmenter` 中文分词质量、人天估算
### ★ A real batch is not just the question
`decision.messages` is the batch admitted for the step, and in a real dsh profile
the human question is **followed by other user-role messages**:
```
[ user question ][ runtime-context snapshot ][ skill catalog reminder ]
```
All three carry `role: 'user'`. So "use the last user message as the recall
query" returns the **skill catalog**, and every real turn recalls nothing.
This plugin's first release had exactly that bug. It was found by running the
plugin in a real headless profile, not by the test suite — the unit tests built
2-message batches, so they never saw it (all checkpoints were green while
auto-recall was 100% dead).
`lastUserText` therefore filters by **`source.kind`**, not by role:
**Allowed** (the recall query may come from these):
| kind | what |
|---|---|
| `user` | **every human prompt**, including remote/browser ones |
**Everything else is injected.** The rule is an allowlist over `source.kind`,
so an unlisted — including a future — kind fails closed.
Two facts make that strict allowlist safe:
1. **`user` is the kind for *all* human prompts, local and remote.** A browser
prompt arrives under a different source *key* (`user-rpc`) but the same
*kind*:
```ts
// dsh-api-session-controller/lib/types/types.d.ts:395-400
'user-rpc': { kind: 'user'; rpcId: SessionRequestId;
clientTimeZone?: string; principal?: Principal }
```
So filtering on `kind` (not on source key, and not on "has no extra fields")
cannot lock out web users. `G6b` pins this.
2. **Injected context always declares its own kind.** Examples verified as
literal declarations in the installed harness:
| kind | source |
|---|---|
| `runtime-context` | `dsh-agent-loop/lib/types/runtime-context.d.ts:13` |
| `skill-catalog` | `dsh-tool-skill/lib/types/index.d.ts:29` |
| `skill-invocation` | `dsh-skill/lib/types/index.d.ts:131` |
| `schedule` | `dsh-schedule/lib/types/runtime.d.ts:5` |
| `agent-message` | `dsh-subagent/lib/types/continuation-messages.d.ts:37` |
| `subagent-settled` | `dsh-subagent/lib/types/continuation-messages.d.ts:38` |
| `agent-instructions` | `dsh-agent-instructions/lib/types/state.d.ts:27` |
| `time-context` | `dsh-time-context/lib/types/index.d.ts:14` |
| `tool-goal`, `tool-jobs` | `dsh-tool-goal/…/index.d.ts:12`, `dsh-tool-jobs/…/index.d.ts:16` |
| `user-approval` | `dsh-user-approval/lib/types/index.d.ts:14` |
| `plan-mode`, `ptc-mode` | `dsh-plan-mode/…/index.d.ts:31`, `dsh-tools/lib/types/ptc.d.ts:14` |
| …and more | see the `G6` table |
**Scope of the test table — read this before trusting it.** `G6` covers *every
user-role injected kind declared in the currently installed harness* (23 of
them). It is **not** a claim of exhaustiveness: the full `MessageSourceMap`
surface is larger and grows with each release. That is fine, because the
allowlist fails closed, so an unlisted kind is safe by construction. What `G6`
actually buys is: a rename in the harness breaks a test here rather than
silently killing recall in production.
`G7` separately pins that a message with **no `source` at all** yields `''`.
Reachability, stated precisely: currently **unreachable** — `assertMessageEventShape`
requires a non-empty `source.kind` and runs on both the append path
(`dsh-session/lib/index.js:1089`) and the seed/restore path (`:1165`). It is
therefore defense in depth against a future format change, not a live bug.
Measured effect (4 seeded notes, three batch shapes):
| batch shape | old rule (last role=user) | fixed rule (last human kind) |
|---|---|---|
| 2-part (the old test shape) | 1 hit | 1 hit |
| 3-part (+runtime-context) | **0 hits** | 1 hit |
| 4-part (+runtime-context +skill-catalog) | **0 hits** | 1 hit |
| **total** | **1 / 3** | **3 / 3** |
Guarded by these tests in `test/plugin.test.js`:
| test | what it pins |
|---|---|
| regression: extraction | the question, not the injection, is returned |
| regression: end-to-end | a real 3-part batch actually recalls (this was 0 hits) |
| regression mutation | reintroducing "last role=user" drops hits to **0** |
| **G6** | all 23 user-role injected kinds in the installed harness are rejected (parameterised over literal declarations) |
| **G6 mutation** | "role is user ⇒ accept" must fail on **all 23** |
| **G6b** | a `user-rpc` prompt (`kind: 'user'` + `rpcId`/`principal`) **is** accepted — the allowlist does not lock out remote users |
| **G7** | a message with no `source` yields `''` |
| **G7 mutation** | dropping the guard admits it, so the guard is load-bearing |
### ★ The lesson: a 2-message batch is a fake batch
The bug above shipped with **every checkpoint green**. The reason is narrower than
"tests were missing":
> `V3b` built its batch as `[question]` — or `[question, injected]`. A real dsh
> step appends injected context *after* the question, so the very shape that made
> the bug invisible was the shape the test chose.
Hence a rule for anything added from here on: **any test that exercises
`agent/pre-step` must build its batch through `realBatch()`**, which always appends
at least a `runtime-context` and a `skill-catalog` message after the question.
A batch of one human message proves the hook fires; it does not prove the hook
reads the right thing. `V3b` is retained (it still pins injection attribution)
but is flagged `V3b⚠` as a known blind spot.
There is **no** hook for "the context is about to overflow, please evict
something", and no waterfall that can rewrite a loop-built request
(`llm/stream` receives a deep-frozen `GenerateOptions` for loop requests —
`dsh-llm/lib/types/index.d.ts:37-42`).
---
## Install
The plugin brings no dependency of its own: everything it imports at runtime is a
`@deepseek-ai/*` **peer** that ships with the dsh install on the machine.
`npm install` does **not** deliver those peers (three of the four are declared
optional), so on its own it leaves a tree that cannot even load the plugin — link
them first, per
[Prerequisite: link the harness packages first](#prerequisite-link-the-harness-packages-first).
```bash
# from this repo
npm install
```
The plugin needs `node:sqlite` (built into Node ≥ 22.5). On Node 22.23 it works
behind an `ExperimentalWarning` only — no `--experimental-sqlite` flag.
Mount it in a dsh composition:
```yaml
- id: memory
name: dsh-memory
config:
path: /path/to/memory.db # optional; defaults to <cwd>/.dsh-memory/memory.db
topK: 3 # optional; recall hits injected per turn
inject: true # optional; false = tool only, no auto-recall
toolName: stablemem_commit # optional; must be unique per tool layer
```
## Use
The model gets one tool:
```
stablemem_commit({ text, source?, scene? })
```
and, once at least one block exists, a constant prompt context line telling it
that durable memory is available.
Recall runs automatically each turn: the last user message is used as the query,
FTS5 + exact-symbol pinning selects up to `topK` blocks, and they are injected as
one attributed user message:
```
[Memory · dsh-memory] Recalled from durable block memory — these are PAST notes, not the current conversation.
[1] (exact 1.01) 值班室分机号 4324,值班人 老周
```
Programmatic use (another plugin, or a preset subtree):
```js
await ctx.stablemem.commit({ text: 'etcd 集群端口 2379', source: 'ops' })
await ctx.stablemem.recall('etcd 端口', { topK: 5 }) // both are async since phase B
await ctx.stablemem.recall('etcd 端口', { topK: 5, signal }) // pass the turn's signal
ctx.stablemem.link({ sourceKind: 'block', sourceId: a, targetKind: 'block', targetId: b, type: 'supersedes' })
```
> `recall(query, 5)` used to work and silently stopped meaning anything when the
> second parameter became an options object — a number is not `{topK}`, so the
> default was used. Both `commit` and `recall` are `async` now; omitting `await`
> on `commit` still writes today only because the no-embedder path has no `await`
> before `putBlock`, which is exactly the fragile assumption W6 guards.
### Phase B · group A — the async seam
`recallBlocks` stays **synchronous and unmodified**; vectors get a separate async
wrapper beside it:
```js
recallBlocks(store, query, opts) // sync, unchanged, no embedder
recallBlocksHybrid(store, embedder, query, opts) // async wrapper
```
Why the split rather than "make recallBlocks async": every existing caller keeps
calling a sync function, so the 9 test call sites and the whole phase-A surface
need no edit, and the golden fixture proves the sync path did not move.
With no embedder (or a failing one) the wrapper is exactly `recallBlocks`, and it
reports why it degraded on the result array itself:
```js
const hits = await recallBlocksHybrid(store, null, q)
hits.degraded // 'no_embedder' | 'embed_error' | 'vector_ready_unused'
```
`degraded` is non-enumerable, so it does not appear in `JSON.stringify`,
`Object.keys`, or deep-equality against phase-A output. It never throws — a dead
embedding service must not take recall down (§0.4).
`'vector_ready_unused'` is deliberate naming: group A computes the query vector but
does **not** feed it into scoring yet, so calling that state `'hybrid'` would be a
lie that outlives its reason. Group V renames it when vectors actually participate.
> Because `degraded` is non-enumerable it is invisible to any serialization. That
> makes it fine for tests and useless for the "degradation must be visible"
> requirement — group D therefore renders counters into the prompt text, not onto
> an object property.
Guarded by `test/phase-b-a.test.js` against
`test/fixtures/golden-phase-a.json`, comparing **id, order, score (exact, not
approximate), the `exact` flag, and the `why` string** across every corpus block
× query × topK case in that fixture. A tolerance-based comparison would let a
scoring change through, which is the one thing this group exists to stop.
> The fixture itself defines how many cases exist — read the file, not this
> sentence. Counting them here would reintroduce exactly the unguarded number the
> rest of this section deliberately avoids.
> **A trap this group found:** making `Svc.recall()` async silently disarmed a
> regression mutation — `await`-ing was missing, `undefined.length` is
> `undefined`, and `undefined < undefined` is `false`, so an assertion comparing
> hit counts *passed while measuring nothing*. Both that test and checkpoint V3c
> now `await`. If a future change makes anything else async, grep for `.length`
> on a value that may have become a Promise.
### Phase B · group G — guards
`test/phase-b-g.test.js` (behavioural) and `test/phase-b-g-static.test.js`
(source-text) pin the properties that keep vectors from damaging phase A:
| # | guard | mutation that must turn it red |
|---|---|---|
| G2 | no embedder ⇒ **zero** network calls (global `fetch` replaced with a tripwire) | add a `fetch` to the no-embedder branch |
| G2b | `lib/` imports only `node:` builtins + dsh peers | add `onnxruntime-node` |
| G3 | `stablemem_commit` still writes when the embedder throws; symbol recall still finds it | make commit await the embedder and reject |
| G4 | only the `text` modality; no media columns exist | add a `data BLOB` column |
| G5 | transport identifiers (`fetch(`, `Authorization`, `Bearer`, `http://`, `model_dir`, `api_key`) may appear **only** under `lib/embed/` | put an endpoint literal in `recall.js` |
| G2† | **every** early-degradation path of `recallBlocksHybrid` is network-free (6 paths, data-driven) | add a `fetch` to any one of them |
| G2†b | the fingerprint check runs **before** `embedText` | move the call earlier |
Two things this group taught us, both about guards rather than about vectors:
1. **A source-scanning guard must not import the code it scans.** G2b/G5 first
lived beside the behavioural guards, which import `lib/index.js`. A broken
`lib/` then crashed the whole file, so the report was "load failed" instead of
"you added a disallowed dependency". They now live in
`test/phase-b-g-static.test.js`, which imports nothing from `lib/` and still
names the offender when `lib/` cannot load at all.
2. **Comment-aware scanning or none.** G5's first version flagged
`"no endpoint"` in a *comment* and `"edge endpoint"` in `store.js`. A guard
that cries wolf gets disabled, which is worse than not having it. It now
strips comments before scanning.
### Phase B · group H — HTTP embedding adapter
`lib/embed/http.js` is the **only** file allowed to know a transport (enforced by
`G5`). Protocol is byte-compatible with HologramAgent's `http` provider
(`internal/memory/vector/http_embedder.go:72-87`), so an existing HA sidecar works
without a shim:
```jsonc
// request
{ "model": "…", "modality": "text", "side": "query", "text": "…" }
// response — either shape accepted
{ "embedding": [ … ] } { "data": [ { "embedding": [ … ] } ] }
```
Enable it:
```yaml
- id: memory
name: dsh-memory
config:
embed:
endpoint: http://127.0.0.1:18999/embed
dimension: 2048
model: jina-v5
# apiKey / fingerprint / timeoutMs optional
```
Omit `embed` entirely to run symbol-only, exactly as phase A did.
**Fingerprint: `http:<model>:<dim>:<sha256(normalised endpoint)[:8]>`**
HA derives `http:<model>:<dim>` (`http_embedder.go:99`) — model and dimension
only. That is unsafe once the endpoint is configurable: two deployments of the
same model name at the same dimension produce **the same fingerprint but
different vectors**, so their blocks compare silently and wrongly. The endpoint
hash closes that. It is computed **once at construction** and reused, because the
same string must reach both tables:
> **Ticket ② constraint.** `memory_blocks.fingerprint` and
> `graph_centroid.fingerprint` must be **byte-identical** for the same space. HA
> looks the centroid up by fingerprint (`block_recall.go:92`), so two spellings
> make the lookup miss with no error — centering just never applies and scores
> quietly degrade.
>
> This is enforced **structurally**, not by discipline: `StableMem.space()` is the
> only source of the string, `commit()` stamps `space().fingerprint`, and
> `saveSpaceCentroid()` takes no fingerprint argument at all — so a caller cannot
> supply a second spelling. The set assertion is
> `SELECT DISTINCT fingerprint FROM memory_blocks` ≡ the centroid keys.
> Mutations verified: prefixing the centroid key, and deriving it from the block
> text, each turn `ticket②-strong` red.
> **Ticket ③ boundary.** A space with embedded blocks but no centroid row reports
> `dim: null`, never `0` — zero reads like a real answer. `loadCentroid()` returns
> `undefined` for a missing row rather than throwing: HA treats a missing or
> under-sampled centroid as normal degradation (`centroid_store.go:144-184`), not
> a recall error. The dimension comes from centroid metadata, never from
> `length(vector)`. The four-state counters belong to groups F/D.
Endpoint normalisation (before hashing, so cosmetic differences don't each cause a
full re-embed): trim, lowercase scheme+host, drop trailing `/`, drop an explicit
default port. It deliberately does **not** reorder query strings or resolve `..`:
those differences are usually real.
### Write path
`commit()` embeds before writing, which adds four obligations the synchronous
path did not have. Each has a one-line mutation that turns its test red:
| obligation | behaviour | why |
|---|---|---|
| **W4** dimension | a vector whose length ≠ `space().dimension` is treated as an embed failure — the block lands with `vector: ''` | recall already skips mismatched vectors, so storing one yields a block that exists, looks fine, and can never be retrieved |
| **W5** cancellation | `exec.signal` reaches `embedText`, where it is fused with the adapter's own timeout; aborting stores the block with no vector | otherwise a cancelled turn holds the call for the adapter's full timeout, and `isConcurrencySafe` siblings queue behind it. **W5 only covers the pipeline — the adapter side is W9's job** |
| **W6** result shape | with no embedder, `commit()` returns the same `{id, created}` as `putBlock` and the block is immediately FTS-recallable | nine call sites relied on "no embedder means it happens synchronously" — true today, silently false the moment an `await` is added |
| **W7** empty fingerprint | checked **before** embedding, so an unnamed space costs zero round-trips | the previous order embedded first and threw afterwards, paying for a result it could never label |
> On **W5**, `isError` is not the assertion: after a body returns, the tool
> registry re-checks the signal and replaces a success with its canonical abort
> result (`dsh-tools/lib/index.js:3408`). What this plugin controls is the *side
> effect* — the block is written — so that is what the test asserts.
>
> **★ W5 initially passed while cancellation was completely broken.** It used a
> hand-written slow embedder that registered its own `abort` listener, so it
> tested the pipeline against a double. The real adapter's `embedText(text)` took
> one parameter, so `embedText(text, signal)` silently dropped the signal: a
> cancelled turn ran the full timeout and then stored the vector anyway — with 99
> tests green. W9 now uses a **real HTTP server and the real
> `createHttpEmbedder`**, and pins both halves of the contract:
>
> | test | asserts | mutation |
> |---|---|---|
> | **W9** | a caller abort interrupts a real request in < 500 ms | make `embedText` single-parameter *and* drop the fuse ⇒ red |
> | **W9b** | the adapter's own timeout still fires when a caller signal is present | drop the timeout from the fuse ⇒ red |
> | **W9c** | end-to-end: aborted tool call still writes the block, `vector: ''`, `code: 'ABORTED'` | as W9 |
>
> Note the mutation had to be *clean*: replacing only the signature left a
> dangling `signal` reference that threw a `ReferenceError`, which W9 read as
> "rejected quickly" and passed. A mutation that fails for the wrong reason is
> not evidence.
### Read-path cancellation (W10) and visible degradation (W11)
**W10** — the write path was fixed in W9; the read path had the same defect and
nobody had looked. `recallBlocksHybrid` called `embedText(text)` with one
argument, and neither `StableMem.recall()` nor `contextForStep()` took a signal —
even though the `agent/pre-step` payload carries one
(`dsh-agent/lib/types/runtime-types.d.ts:304-310`). Measured on the real adapter
with a hanging server: **2004 ms** for an abort issued at 50 ms; at the shipped
30 s timeout, *every pre-step could block for 30 s with cancellation having no
effect*.
The signal now flows `agent/pre-step payload.signal → contextForStep → recall →
recallBlocksHybrid → embedText`, and W10 measures the fix through the real hook
chain (not by calling the seam directly): **54 ms**.
| test | asserts | mutation |
|---|---|---|
| **W10** | aborting a pre-step returns in < 500 ms, degrade reported | drop `options.signal` at `embedText` ⇒ red, **30001 ms** |
| **W10b** | the same at the seam, so a hook refactor cannot hide a regression | as above |
| **W11** | a degraded recall puts a readable notice in the rendered text | remove the notice line ⇒ red |
| **W11d** | degraded **and** zero hits still injects the notice (no entries) | restore either short-circuit ⇒ `W11d` red |
| **W11e** | default config (no embedder) injects **nothing** on ordinary turns | make `no_embedder` speak again ⇒ `W11e` red |
| **W12** | `aborted` and `embed_error` carry different labels and different wording | collapse the two labels ⇒ red |
| **W11f** | no mode promises "matches below" when there is nothing below; every mode points at the entries when there are | drop the `aborted` split ⇒ red; drop the `default:` split ⇒ red |
| **W11b** | a healthy recall renders no notice | — |
| **W11c** | end-to-end: aborted pre-step text still carries the hit *and* the notice | remove the notice line ⇒ red |
**W11e — a standing fact must not be repeated every turn.** W11d made
"degraded + zero hits" speak up, which is right for a *per-turn event* and wrong
for a *standing configuration fact*. Without an embedder is the shipping default,
and `renderIndex()` already tells the model every turn that block memory exists.
Measured before the fix, phase-A default config, ten ordinary turns
("今天天气怎么样", "你好", "谢谢", …):
```
10 injected, 2130 chars, mean 213 chars/turn
```
The cost is not really the tokens — it is that a constant, entry-less memory block
teaches the model to skip the section, defeating the purpose of W11 for the
degradations that matter. W11d and W11e are mutual guards: a per-turn event
speaks, a standing state stays silent.
**Mutation discipline (learned four times over).** A mutation must modify a file
under `lib/`. A test that builds its own stand-in function, patches a local arrow,
or otherwise asserts on something it just wrote is a *self-test*: it cannot fail
when `lib/` regresses. Two such tests (formerly `W11d†a`/`W11d†b`) were green
under both real mutations while `W11d` itself went red — they have been deleted.
The surviving guards (`W11d†`, `W11d†ctx`, `W11e†`) apply the mutation to a
**temporary copy** of `lib/`, import that copy, and assert the behaviour flips.
If a mutation anchor no longer matches they **fail loudly** rather than quietly
passing, so a refactor cannot silently disarm them.
> The first version of these guards patched `lib/` **in place** and restored it.
> That raced every other test file: `node --test` runs files in parallel
> processes, so another suite could import the module inside the mutated window.
> It produced roughly a **1-in-30** spurious failure whose symptom looked like a
> flaky assertion rather than a harness bug — the same class of "sometimes green"
> that is worst to debug. Mutating shared state is never safe to run concurrently
> with the code under test, so the mutation now happens on a copy. Verified with
> 30 consecutive full runs: 0 failures.
> Why the notice exists at all: `hits.degraded` is non-enumerable (group A needs
> the golden comparison undisturbed), so it is invisible to `JSON.stringify`,
> `Object.keys`, and every serialised log. **A test asserting that property proves
> nothing about what the model receives.** The rendered text is the only channel,
> so "degradation is visible" has to be asserted on the text.
### Building the centroid (W8)
`rebuildCentroid(fingerprint)` is the entry point that had no caller: without it
blocks carried vectors while `graph_centroid` stayed empty forever, `loadCentroid`
always missed, and centering silently never applied.
Ported from HA's `RebuildCentroid` (`centroid_store.go:246-320`): only rows from
that space, a mismatched dimension is skipped rather than coerced, the mean is
accumulated over L2-normalised vectors, and the mean's norm decides whether to
centre at all (`MEAN_THRESHOLD = 0.6`, `centroid.go:87`).
**Who triggers a rebuild is still an open C-group decision** (post-commit ratio vs
lazy pre-step check vs an explicit tool). What exists now is the mechanism plus a
test corpus that sits *between* 0.6 and 0.95 — collinear vectors give mean length
1.0 and would pass any threshold, which is exactly how the first version of that
test failed to distinguish the mutation.
Failures are **hard at the adapter** and **soft at the recall layer**:
| layer | behaviour |
|---|---|
| adapter | non-2xx / no embedding array / wrong dimension / oversized body / timeout ⇒ throws |
| `recallBlocksHybrid` | catches, sets `degraded: 'embed_error'`, answers from FTS5 |
A wrong dimension is never truncated or padded — a silently mis-sized vector
produces a plausible-looking but meaningless score (`block_recall.go:135-138`).
### Schema migration
`PRAGMA user_version` gates the upgrade (currently `0 → 1`).
Phase A shipped only `CREATE TABLE IF NOT EXISTS`, which is a **silent no-op** on
an existing table. A database created by the phase-A build therefore opened
without error, recalled fine through FTS, and then threw on the **first commit**:
```
PUTBLOCK THREW: table memory_blocks has no column named vector
```
The memory store became read-only with no diagnostic at load time — a worse
failure shape than a loud error, because nothing looks wrong until someone tries
to write.
The migration probes `pragma_table_info` instead of guessing from the version
number (a version-0 database created by a crashed phase-B build may already have
the columns), then `ALTER TABLE ... ADD COLUMN` with `DEFAULT ''` — the same
"not embedded yet" sentinel `putBlock` uses.
`graph_centroid` needs care: phase A created it with different columns, and the
DDL has **already run** by the time the migration sees the table, so
`CREATE TABLE IF NOT EXISTS` was a no-op. Dropping the old table without
rebuilding produces the narrowest failure in the codebase — the store opens
cleanly, stamps `user_version = 1`, accepts `putBlock`, and throws
`no such table: graph_centroid` only on the first `saveCentroid`. The rebuild
therefore happens **inside** the migration (`DDL_CENTROID`).
A populated old centroid table is renamed to `graph_centroid_phase_a_<timestamp>`
rather than dropped (data loss) or treated as fatal (the whole database would
refuse to open, for a shape the plugin could not produce by normal use). Startup
continues, the old rows stay inspectable with `sqlite3`.
| test | what it pins | mutation that must turn it red |
|---|---|---|
| MIG1 | phase-A `memory_blocks` gains both columns; legacy block readable, FTS-matching, and writable | comment out `ADD COLUMN vector` |
| MIG2 | a fresh database starts at version 1 | skip `migrate()` |
| MIG3 | phase-A database can `saveCentroid` + `loadCentroid` after migration | drop the centroid rebuild |
| MIG3b | a populated phase-A centroid is renamed aside, not fatal, not lost | revert to `throw` |
Verified end to end: a real phase-A database opens, reports `user_version = 1`,
accepts a commit, and its legacy block is still recallable.
---
## Schema
Three tables, mirroring HologramAgent's (`internal/memory/graph.go:2577`, `:320`,
`centroid_store.go:58`), restricted to the text modality:
| table | purpose | notes |
|---|---|---|
| `memory_blocks` | the block payload | `text_content` is the body; `source` / `scene` / `semantic_type` mirror HA's columns |
| `memory_block_edges` | structural + semantic edges | unique per `(source_kind, source_id, target_kind, target_id, type)` |
| `graph_centroid` | vector-space centering | **created empty** — phase B writes it; creating it now avoids a migration |
| `memory_blocks_fts` | FTS5 index over `text_content` | `unicode61 remove_diacritics 2` |
`memory_blocks.id` is content-addressed
(`blk_` + `sha256(text)[:12]`, ported from `distill.BlockID`,
`internal/memory/distill/blocks.go:99`), so re-committing identical text is
idempotent and never blanks existing metadata.
---
## Recall design, and why there are no vectors yet
This slice ports HologramAgent's **symbol path**, not its vector path.
`docs/zh/recall-capability-tiers.md` measures four query shapes. The one that
works *best* in production is 形态 1 (queries carrying an exact token: port, IP,
path, version) — which is pure symbol matching. The one that needs vectors is
形态 3 (pure generic description), and it is the weakest even *with* vectors
(target block ranked 389/2686). So dropping embeddings here costs the tier that
was already marginal, not the tier that carries the product.
Scoring follows HA exactly where it matters:
- An **exact numeric hit** is a *boolean pin* (`1.0 + lexical*0.01`), never a
weight. Folding it into a weighted sum makes it lose to noise — the regression
documented at `internal/memory/fusion.go:43-49`.
- A matched **word** is a coverage signal, not an exact hit.
- Single-character symbols are rejected (`abstain.go:115` "单字不成符号");
without that gate a query like `billing0` yields a bare `0` that "exactly"
matches every block containing a zero.
- A matched word prefers blocks where it is **not a prefix of a longer word**
(`billing` should beat `billing2`), loosely mirroring
`isCrossWordTailMatch` (`distill/split.go:383`).
Two documented gaps, asserted rather than hidden (see checkpoint V2):
| gap | why | when |
|---|---|---|
| 纯泛化描述 queries ("公网地址是什么") | needs vectors | phase B |
| 「库里没有」detection (abstention) | needs either vectors or a real CJK tokenizer | phase B/C |
| CJK segmentation | `unicode61` keeps a CJK run as ONE token; compensated by a 2..4-char sliding-window fallback ported from `cjkWindows` (`fusion.go:240`) | phase C |
---
## Three harness constraints this slice discovered
Each was found by a failing test, not by reading types — they are recorded here
because they will bite anyone writing a second dsh service plugin.
1. **A cordis service must not use JS `#private` fields.**
`ctx.stablemem` is a **proxy**, not the instance you constructed, and private
fields are bound to the declaring object:
`Cannot read private member #store from an object whose class did not declare it`.
Keep service state in ordinary properties.
2. **`isolate()` does not partition prompt-context names or tool names.**
It partitions *service* lookups. Two instances in one realm still collide on
`systemPrompt.context({name})` (`dsh-system-prompt/lib/index.js:204`) and
`tools.register` unless the names differ. Hence the configurable `toolName`,
and the prompt-context name derived from the database path
(`memory:index:<key>`), which stays unique and stable.
3. **`ctx.systemPrompt.getContextOrder()` is an allocator, not a free constant.**
Prefer a named slot (`WEB_SURFACE`) over a magic number; the numeric constants
are private to the package.
---
## What this slice does not do
| not done | why | where it lands |
|---|---|---|
| embeddings / vectors | no embedding service in dsh (`docs/feasibility.md` §5.1) | phase B |
| evicting L1 context into memory | dsh's compaction is summary-replacement, and `CompactionTrigger` carries no query (§5.3) | replaced by shadow-context + preset |
| media blocks | dsh attachment is CAS; HA requires bytes inside the block (§5.4) | not planned |
| cross-session / cross-agent memory | one sqlite file per `config.path` | phase D |
| abstention | needs vectors or a real tokenizer | phase B/C |
---
## Verify
### Prerequisite: link the harness packages first
Nothing here is fetched as this plugin's own dependency. The `@deepseek-ai/*`
packages that `lib/` imports at runtime are **peers**: they belong to the dsh
install already on the machine, and they are not vendored here on purpose
(a copy would drift from the harness this plugin is supposed to run inside).
`npm install` does not resolve them either — `cordis` is the only one of the four
that is non-optional, so npm fetches that one (plus its own transitive deps) and
leaves the other three missing.
A fresh clone therefore needs one setup step before the suite can run:
```bash
bash scripts/link-dsh.sh # symlinks the installed dsh packages into node_modules
```
**Skip it and `npm test` and `npm run check` both fail with four files
reporting `ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/…'`** —
`loader`, `plugin`, `phase-b-g` and `phase-b-h` (the suites that mount a real
dsh app). Those four die at import, so the run reports a handful of tests rather
than the full suite. That is the expected symptom of a missing setup step, not a
broken tree; run the script and both commands go green.
The script takes an optional argument for a non-default install root:
```bash
bash scripts/link-dsh.sh /path/to/global/node_modules
```
### Run
```bash
npm test # the full suite
npm run check # human-readable evidence, one line per checkpoint
```
> **No test or checkpoint counts are stated here on purpose.** A number in a
> README is an unguarded claim: it drifts every time someone adds a test, and a
> stale count reads as precise while being wrong. Guarding it cost more than the
> number was worth (a per-run source scan of the suite). The tables below name
> *what* is verified; `npm test` / `npm run check` are the authority on *how many*.
`npm run check` prints one line of evidence per checkpoint. The mapping to
`docs/feasibility.md` §7.1:
> Writing or changing a criterion? Read
> [`docs/dev-notes-mutation-criteria.md`](docs/dev-notes-mutation-criteria.md) first. It
> records the three ways a mutation test silently stops working — self-tests that
> never touch `lib/`, hardcoded enumeration lists, and mutating shared state under
> a parallel test runner — all of which were hit and independently reproduced
> during this phase.
| checkpoint | proves |
|---|---|
| **V1** | `node:sqlite` opens, WAL applies, 100 writes round-trip |
| **V2** | recall quality per capability tier: tiers 1–2 must pass, tiers 3–4 pinned as gaps |
| **V3** | `systemPrompt.context` exposes `agent` and **cannot** see this step's messages |
| **V3b** | `agent/pre-step` injection lands in the accepted batch with our `source.kind` |
| **V3b⚠** | *known blind spot:* V3b's batch is 2 messages, so it cannot detect the real-batch bug. The regression tests in `test/plugin.test.js` cover it — see above |
| **V3b†** | mutation — silencing recall makes V3b fail (the test can turn red) |
| **V4** | `tools.register` schema + `render` contract, executed end to end |
| **V5** | fiber disposal closes the handle; the file is reopenable |
| **A5†** | mutation — containment-only recall loses discrimination (why FTS is primary) |
| **MIG3** | a phase-A database opens, migrates both tables, and stays fully writable (blocks *and* centroid) |
Plus three regression tests for the real-batch bug described above
(`test/plugin.test.js`): extraction, end-to-end 3-part batch, and a mutation that
reintroduces the old rule and asserts the hit count falls to zero.
### Expectations from a real run
Pasted from an actual `npm run check` (not hand-maintained — this section drifted
twice by being transcribed by hand):
```
dsh-memory · phase A checkpoints
✔ V1 node:sqlite opens, WAL applies, 100 concurrent writes round-trip
journal=wal, rows=100, mid-batch row=blk_cad13afe6824, file on disk
✔ V2 recall quality per capability tier (tiers 1–2 must pass; 3–4 are pinned gaps)
tier1(exact) 30/30 = 100% · tier2(domain) 20/20 = 100% · known gaps: 泛化查询→0 hit, 库里没有→0 hit (需向量,phase B)
✔ V3 systemPrompt.context: sees agent, cannot see this step’s messages
AssembleContext keys = [agent] — no messages/input field
✔ V3b agent/pre-step: injected recall lands in the accepted message batch
batch 2 msgs; injected kind=dsh-memory, carries "4324"
✔ V3b† MUTATION: silencing recall must break V3b (the test can turn red)
with recall disabled the batch stays at 1 message ⇒ the V3b assertion would fail
✔ V3c realistic 3-part batch: recall uses the question, not the injected context
batch 4 msgs, hit "4324" · old-rule hits=0 vs fixed hits=1
✔ V4 ctx.tools.register: stablemem_commit validates args and renders output
1 tool(s) visible; execute → "Committed block blk_0be019e8b0e4 (stored without a vector)."
✔ V5 unload disposes the sqlite handle; a second close is a no-op
fiber dispose → handle closed; reopened same file successfully
✔ A5† MUTATION: swapping FTS5 for containment breaks discrimination
FTS+pin → 1 hit for "4324"; containment → 2 hits (discrimination lost)
✔ MIG3 phase-A database opens, migrates, and stays fully writable
uv=1, blocks=10 cols, centroid=7 cols, legacy block readable + FTS-matching
<all checkpoints passed>
plugin: name=dsh-memory inject=[systemPrompt, tools]
workspace: /tmp/dsh-memory-cp-9IOx5k (removed)
```
---
## Ported from HologramAgent
Source is read-only reference (`/home/program/TrueAgent`); nothing is vendored.
| this repo | HologramAgent |
|---|---|
| `lib/store.js` `BlockStore` | `memory.GraphDB.PutMemoryBlocks` (`internal/memory/block.go:203`) |
| `lib/store.js` `blockId` | `distill.BlockID` (`internal/memory/distill/blocks.go:99`) |
| `lib/recall.js` `querySymbols` | `memory.QuerySymbols` (`internal/memory/fusion.go:145`) |
| `lib/recall.js` scoring / pin | `memory.SymbolScore` (`fusion.go:284`), pin at `:427` |
| `lib/recall.js` `isNumericSymbol` | `fusion.go:316` |
| `lib/recall.js` `cjkNeedles` | `cjkWindows` (`fusion.go:240`) |
| `lib/recall.js` single-char gate | `AbstainCheck` guard (`abstain.go:115`) |
| `lib/index.js` `renderIndex` header | `crossModalMarkdown` (`internal/agent/core/crossmodal.go:256`) |
## License
MIT

29
cordis.patch.yml Normal file
View File

@ -0,0 +1,29 @@
# Ship-ready overlay that swaps the harness loop for `dsh-memory/loop`.
#
# This is the ONLY artefact that proves the replacement loop is mounted: without
# it `lib/loop.js` is a subclass nobody instantiates (its unit tests exercise the
# pure `promoteSnapshotSections` function and nothing else).
#
# Apply it to a profile (a `--patch` overlay, or copied into the profile's own
# `cordis.patch.yml`); `dsh` composes it over `@deepseek-ai/dsh-base`, where the
# `agent-loop` row already exists:
#
# dsh --profile headless --patch <this file> "<task>"
#
# ★ The disable is not optional, and it is by ID, not by name. `AgentLoop`'s
# constructor ends with `ctx.effect(() => ctx.agents.setFactory(this))`
# (dsh-agent-loop/lib/index.js:1590), and `dsh-agent`'s `setFactory` throws when a
# factory is already registered (dsh-agent/lib/index.js:429-437). Leaving the base
# row enabled makes startup fail with "an agent factory is already registered";
# `HologramAgentLoop` inherits that constructor, so it registers exactly once —
# but only if the base row is gone.
- id: agent-loop
disabled: true
# The replacement. No `config` block on purpose: `HologramAgentLoop` defines no
# `static Config`, so it inherits the parent schema (`maxParallelToolCalls`,
# `agents`) and the profile's own `agent-loop` config rows keep working through
# the inherited constructor.
- insert:
- id: hologram-agent-loop
name: dsh-memory/loop

View File

@ -0,0 +1,363 @@
# 变异判据的四类失效模式 + 两条纪律
> 来源:phase B(H 组 W1–W12)三轮返工中实际踩到、并由评审独立复现的坑。
> 四条都不是本任务特有的,**任何写变异判据的人都会再踩**。第五、六类是纪律,不是失效模式。
>
> 共同点:**它们都让判据绿着,而判据守的东西已经坏了。** 这类问题比被测代码坏了更难发现,
> 因为失败的信号本身就是「一切正常」。
---
## 0. 一句话总纲
> **变异体必须让 `lib/` 下的代码变坏。**
> 一旦变异动的是 `test/`、或者断言的是测试里自己拼的局部函数,它就不是变异,是自测。
把这一条当硬规则执行,下面四条里前两条会被自动拦住。
---
## 1. 自测型假变异(犯得最多:四次)
### 形态
"变异"测试在 `test/` 里自己拼一个替身,再断言替身的行为:
```js
// ✗ 这不是变异
test('W11d†a: restoring the short-circuit breaks it', () => {
const withShortCircuit = (hits) => (hits.length === 0 ? '' : renderRecall(hits)) // 自己拼的
assert.equal(withShortCircuit(empty), '', 'the short-circuit does silence it')
})
```
这段**一行 `lib/` 代码都没碰**。它永远不会随实现回滚而变红,却按「已覆盖一条变异」计数。
### 实测证据
| 变异(真改 `lib/`) | 期望 | 实测 |
|---|---|---|
| M-a 只回退 `renderRecall` 短路 | `W11d†a` 红 | **`W11d†a` 绿**,只有 `W11d` 红 |
| M-b 只加回 `contextForStep` 短路 | `W11d†b` 红 | **`W11d†b` 绿**,只有 `W11d` 红 |
### 正确形态
真改源码 → 重新 import → 断言行为翻转 → 还原。
```js
await withMutatedCopy('recall.js', [
[' if (lines.length === 0 && notice === \'\') return \'\'',
' if (lines.length === 0) return \'\' // MUTATED'],
], async (mod) => {
const hits = []; hits.degraded = 'embed_error'
assert.equal(mod.renderRecall(hits, 'x'), '', 'mutation makes it render nothing')
})
```
三条要求:
1. **改的是 `lib/`**,不是 `test/`。
2. **锚点找不到时显式失败**(`assert.ok(text.includes(from))`),否则源码一改名,变异静默失效。
3. **改在副本上**,见 §3。
### 自查问句
> 把 `lib/` 完全回滚到上一个版本,这条测试会红吗?
> 不会 → 它是自测,删掉,别留在计数里。
---
## 2. 清单写死(判据的输入与代码脱节)
### 形态
判据遍历一份**手写**的清单,而清单本该覆盖的东西由代码决定:
```js
const MODES = ['no_embedder', 'embed_error', 'aborted', 'vector_ready_unused',
'future_mode_a', 'future_mode_b'] // ✗ 手写
```
`future_mode_a/b` 这类名字有误导性:它们测的是「**掉进 `default:` 的**未知模式」,
永远测不到「**有人新写了一个 `case`**」。而正常演进偏偏就是新写 `case` ——
那种时候 `default:` 和手写清单**两头都管不到**。
### 实测证据
在 `lib/recall.js` 里植入一个**会撒谎**的新分支:
```js
case 'brand_new_mode':
return '[Memory] New mode degraded; matches below may be incomplete.' // 零命中时是谎话
```
| 版本 | `W11f` | `W11f†` | `W11f†b` |
|---|---|---|---|
| 写死 `MODES` | **绿** | 绿 | 绿 |
| 源码扫描 | **红** | 绿 | 绿 |
写死版全绿,因为 `brand_new_mode` 不在数组里。
### 正确形态
**清单从被测代码推导**,而不是手抄:
```js
const src = readFileSync(new URL('../lib/recall.js', import.meta.url), 'utf8')
const body = src.slice(src.indexOf('export function degradationNotice('))
const declared = [...body.slice(0, body.indexOf('\n}\n')).matchAll(/case '([^']+)':/g)]
.map((m) => m[1])
```
### ★ 但「自动推导」不能替代「提取本身也要被守卫」
推导源必须是判据真正要管的那件事。`case 'literal'` 恰好就是「有哪些模式」的唯一事实源;
一旦 `degradationNotice` 改成表驱动(模式放进映射表),扫描就会**静默扫到空数组而照样绿**。
所以必须配两道:
| 守卫 | 作用 | 实测 |
|---|---|---|
| `declared.length >= 4` + 必需 mode 存在性 | 提取失效时**响亮失败** | 把 `case 'x'` 全改成常量引用(语法合法、仍可导入)→ `expected at least 4 case literals, found 0` |
| `W11f-a`(guards the guard) | 用变异塞一个新 `case`,断言扫描**看得见**它 | 塞 `mode_added_by_the_future_test` → 能取到 |
`W11f-a` 不是冗余:**「扫描器坏掉」是这套判据唯一能静默失效的方式。**
### 同一族的另一种:README 里的数字
README 曾写着「N tests / M checkpoints」,被手改过五次,两次是在 commit message 里漂的。
加守卫来盯着这个数字,成本高于数字本身的价值。
**结论:删掉,不守着。** 留下的守卫反过来断言「README 不得出现计数」:
```js
[/\b\d+\s+tests\b/i, 'a test count'],
[/\b\d+\s+checkpoints\b/i, 'a checkpoint count'],
[/\b\d+\s*\/\s*\d+\s+checkpoints passed/i, 'a pasted pass-count'],
```
三种写法实测都能抓住。**一个没有人验证的数字,给人一种精确的假象。**
> 注意区分两类数字:
> - **手抄的运行计数**(「119 tests」)——会漂,删。
> - **制品自身的内容量**(「fixture 里有 45 个 case」)——由文件决定,但同样没有守卫,
> 所以也不要写进文档,改为指向文件本身。
---
## 3. 变异共享状态与并发撞车(ESM / 测试并行)
### 形态
`node --test` **默认并行跑测试文件**(每个文件一个进程)。变异若直接改写共享的 `lib/*.js`:
```js
// ✗ 与其它测试文件并发时不安全
writeFileSync(src, mutated) // 别的文件此刻可能正在 import 它
const mod = await import(`${url}?mut=1`)
writeFileSync(src, original)
```
别的测试文件在变异窗口内 import,就读到中间态。
### 实测证据
- 症状:**约 1/30** 偶发失败,看起来像「断言 flaky」而不是 harness 竞态。
- 具体表现:`W11d†` 抓到的是**未变异**的模块,于是断言失败但原因完全错位。
- 更隐蔽的一层:`A1b`(逐字节比对 `recallBlocks` 源码)、`G2b`/`G5`(扫 `lib/` 全目录)
这三条**专门读源码的判据**,在就地变异下会读到被改坏的中间态。
### 正确形态
**在副本上变异**:
```js
const dir = mkdtempSync(join(tmpdir(), 'mut-'))
cpSync(libDir, join(dir, 'lib'), { recursive: true })
cpSync(join(pkgRoot, 'package.json'), join(dir, 'package.json'))
cpSync(join(pkgRoot, 'node_modules'), join(dir, 'node_modules'), { recursive: true, dereference: false })
// 改 dir/lib/<file>,import dir/lib/<file>
// finally: rmSync(dir, ...)
```
要点:
1. 复制 **`lib/` 整体**(保留目录形状,相对 import 才能在副本内解析到被变异的兄弟模块)。
2. 复制 `package.json`(`"type": "module"`)。
3. `node_modules` **软链/保留符号链接**(`dereference: false`),否则 peer 依赖解析不到。
4. `finally` 里 `rmSync`,否则 `/tmp` 堆副本。
5. 锚点匹配不到时**显式失败**,别静默跳过。
### 验证方式
- 连续 30 次全量跑,0 失败(改前约 1/30 偶发)。
- 跑完 `ls -d /tmp/mut-?????? | wc -l` 应为 0(无残留)。
- 并行压力:`a + g-static + h` 三文件同跑 5 轮 0 红。
> 附带的 ESM 坑:`package.json` 里 `"type": "module"` 时,测试文件不能用 `require()`,
> 一律 `await import('node:fs')`。用 `require` 会报
> `Cannot determine intended module format`(顶层 await 与 require 并存)。
---
## 4. 判据被替换时,旧判据的覆盖静默消失
### 形态
一条测试同时承担两件事。你删掉其中一件时,另一件的覆盖跟着没了——
**而没有任何东西会因为少了这个覆盖而变红。**
本阶段真实发生:旧的 README 守卫同时含两条断言:
```js
// ① README 的计数与实现一致(后判定不值得守)
assert.equal(Number(claim[1]), declared, ...)
// ② checkpoints.js 至少注册了一条 ← 值得守
assert.ok(declared > 0, 'no checkpoint calls found — did the helper get renamed?')
```
删①时把整条 test 换掉了,②一起消失。
### 实测后果(很安静)
把 helper 换成一个什么都不做的空操作(调用点全保留):
```
$ npm run check
0/0 checkpoints passed
$ echo $?
0 ← 退出码 0
$ npm test
# tests 119 # pass 119 # fail 0 ← 全套仍全绿
```
**checkpoint 全废了,`npm run check` 报「通过」,119 条测试一条都不红。**
### 正确形态:守卫要守结果,不要守成因
补回一条静态计数只能抓「helper 改名」。放在 runner 里守**结果**,能覆盖所有成因:
```js
if (results.length === 0) {
console.error('no checkpoints were registered — did the helper get renamed or stubbed?\n')
process.exitCode = 1
}
```
| 成因 | runner 内守卫 | 静态计数 |
|---|---|---|
| helper 改名 | ✔ | ✔ |
| helper 被 stub 成空操作 | ✔ | ✘(调用点还在,计数不变) |
| 条件跳过(`if (skip) return`) | ✔ | ✘ |
| 提前 `process.exit(0)` | ✘(**拦不住**) | ✘ |
最后一行是关键:**`process.exit(0)` 绕过任何脚本内的检查**。所以还要一条外部断言,
证明 runner 里仍然含有那道守卫:
```js
assert.match(script, /if \(results\.length === 0\)/)
assert.match(script, /results\.length === 0[\s\S]{0,200}process\.exitCode = 1/)
```
### 自查问句
> **这条判据除了它名字里说的那件事,还顺手守了什么?**
>
> 删/改之前,把它的断言逐条过一遍,对每一条问:
> 「这条如果不守了,谁会红?」——答案是「没人红」的那些,要么补新守卫,要么明确记下放弃。
> 附一条探针经验:验证「零注册」不要用「把 `await checkpoint(` 注释掉」——
> 那会留下孤立的 `return`,报 `SyntaxError: Illegal return statement`,测的是语法。
> 用「换成一个仍存在但什么都不做的函数」,调用点保持不变,才是有效探针。
---
## 5. 判据降级时,覆盖面必须一起交代
不是失效模式,是一条**记账纪律**:判据被削弱时,削弱本身要写进判据注释,
否则后来人会把它当成完整证据。
本阶段实例:`A1b` 原本逐字节锁死 `recallBlocks` 源码,F2 合法地要改这个函数,
于是降级为「不传 fingerprint 时行为不变」。但降级后的 `A1b-behaviour`
只跑 golden 的**前 5 个** case —— 变异 A(非精确带分数微调)下它可能仍是绿的。
```
所以注释里必须写明:
结论不由 A1b 单独给出,而由 A2 兜(A2 走全部 45 例、比精确分数)。
A1b-behaviour 只是 no-fingerprint 路径的廉价烟测,不是权威。
```
自查问句:
> **这条判据降级后,原本由它兜住的结论现在由谁兜?**
> 答案必须是一个具体的、覆盖更广的判据名;答不出来就说明降级降过头了。
**反例(同一纪律缺失的样子)**:只把测试改窄、不写「为什么不影响结论」,
后来人看到绿就以为全绿 —— 与「自测型假变异」的最终效果一样,只是路径不同。
---
## 6. 恒为 0 / 恒为真 的字段(判据咬不住的占位符)
**形态**:字段存在、被赋值、有测试,但那个值永远不变。
本阶段两次实例,都是同一类:
```js
// ① 手抄常量
assert.equal(hits.skipped, 2) // fixture 一改就悄悄不再描述事实
// ② 占位符冒充度量
last_recall.skipped_dim = 0 // 注释:"reported as 0 rather than guessed"
assert.equal(typeof last.skipped_dim, 'number') // 常量永远满足
```
②比①更隐蔽:①至少写了个具体数字,②连数字都没有,只有一个类型断言,
看起来像在测、实际什么都没测。而它顶着的是一条真实需求
(F4「维度失配与指纹失配分开计数」)—— 于是那条需求在文档上成立、
在代码里是摆设。
**修法二选一,不许两头留**:
| 选项 | 做法 |
|---|---|
| 让它能非零 | 补上产生该值的路径(本例:召回路径加维度过滤),并加判据断言非零 |
| 认账删掉 | 从对外形状里删掉该字段,并同步改文档,写明它不在这一层管 |
**自查问句**:
> **这个字段的取值集合里,有没有一个能让我这条断言失败?**
> 没有 → 它不是度量,是占位符。
**同族的第三种**:`typeof x === 'number'` / `x !== undefined` / `x.length >= 0`
这类断言对任何实现都成立,等于没写。它们和「恒 0 字段」是一回事:
断言存在,但取值空间被断言本身排除了失败的可能。
---
## 通用自查清单
写/改一条变异判据前,逐条过:
- [ ] 变异体**只改 `lib/`**(改 `test/` 的是自测)
- [ ] 把 `lib/` 回滚到上一版,这条测试**会红**
- [ ] 锚点找不到时**显式失败**,不是静默 pass
- [ ] 清单/枚举从**代码推导**,不手抄
- [ ] 推导本身有**长度下限 + 必需项**断言;另有一条 guards-the-guard
- [ ] 变异在**副本**上做,`finally` 清理,不碰共享树
- [ ] 跑完确认**无残留**(`.orig` / `.rej` / `/tmp/mut-*`)
- [ ] 连跑 **≥5 次**(间歇性问题 3 次绿证明不了什么)
- [ ] 文档里**没有**手抄的运行计数
- [ ] 替换/删除旧判据时,逐条问过「这条覆盖现在谁守」
- [ ] 判据被降级/收窄时,注释里写明「结论现在由谁兜」
- [ ] 没有恒为 0 / 恒为真的字段;每个被断言的度量都至少能取一个让断言失败的值
## 一句话记住六条
| 坑 | 一句话 |
|---|---|
| 自测型假变异 | 变异不碰 `lib/`,它就永远不会红。 |
| 清单写死 | 判据的输入和代码脱节时,绿的是记忆不是事实。 |
| 并发撞车 | 变异共享状态不能与代码并发跑 —— 改副本。 |
| 替换带走覆盖 | 一条判据往往守两件事;删一件时另一件不会有人提醒你。 |
| 降级不记账 | 判据收窄后,原本由它兜的结论必须有新名字接手,且写进注释。 |
| 恒值字段 | 一个永远不变的值配一个 `typeof` 断言,等于没测。 |

437
docs/phase-b-criteria.md Normal file
View File

@ -0,0 +1,437 @@
# Phase B 判据与变异规则草案
> 状态:**草案,待批**(decision 2 未回)。不含实现。
> 前置已定:
> - **decision 1**:向量走 HA 的 `http` provider 转发,不背 ONNX;`graph_centroid` 按 http 的维度语义写入。
> - **decision 3**:保留「跳过不报错」,但失配计数必须暴露到工具面(可查询),不许只留日志。
> 范围:只覆盖**向量落地**(`docs/feasibility.md` §7.1 阶段 B)。L2 冷文档、影子上下文不在本文。
>
> 取证:HologramAgent 源码只读(`/home/program/TrueAgent`)+ 本插件 phase A 现状。
---
## 0. 开工前必须先定的 4 件事(含我的建议)
这 4 条不定死就写实现,后面每个都要动召回主路径。按我判断的严重度排序。
### 0.1 ★ fingerprint 的派生式漏了 endpoint(这是我原方案的隐患)
HA 的 http provider 这样派生指纹(`internal/memory/vector/http_embedder.go:99`):
```go
if cfg.Fingerprint == "" {
cfg.Fingerprint = "http:" + cfg.Model + fmt.Sprintf(":%d", cfg.Dimension)
}
```
**指纹只含 model + dimension,不含 endpoint。** 后果:
| 场景 | 指纹 | 实际向量空间 | 结果 |
|---|---|---|---|
| `endpoint=A, model=jina-v5, dim=2048` | `http:jina-v5:2048` | A 的权重 | — |
| 改成 `endpoint=B, model=jina-v5, dim=2048` | `http:jina-v5:2048` | **B 的权重** | **指纹相同 ⇒ 静默混用两个空间** |
「同名模型、不同部署」是常见情况(换机器、A/B 两个向量服务、云端换 region)。HA 这条在单机单服务下没暴露,插件形态下我们更容易遇到(用户自己填 endpoint)。
**建议**:指纹 = `http:<model>:<dim>:<endpoint 的 8 位 hash>`,并允许 `config.fingerprint` 显式覆盖(HA 已有这个口子)。
**代价**:换 endpoint 会触发一次全量重嵌。但那本来就是对的——不同 endpoint 可能就是不同权重。
> 需要你拍板:**采纳扩展派生式,还是保持与 HA 逐字同构**?
> 我倾向扩展,理由如上;若你要求「与 HA 同构优先于正确性」,我就退回原式并在计数器里额外暴露 `endpoint`,让运维能自己看出两个服务同名。
### 0.2 ★ 空 fingerprint 的块现在会被静默放行
HA 的跳过条件是三个 `&&`(`internal/memory/block_recall.go:140-143`):
```go
if q.Fingerprint != "" && b.Fingerprint != "" && b.Fingerprint != q.Fingerprint {
skippedFP++
continue
}
```
⇒ **块的 fingerprint 为空时不跳过**。也就是说「历史遗留、没有指纹的带向量块」永远被视为可比。
而 `BlockVectorStats` 单独统计了这种块(`block_recall.go` 的 `MissingFP`),说明作者知道它存在——但在召回路径上没拦住。
对本插件的影响更具体:**phase A 写进去的块全都没有向量**。phase B 回填时,"无向量"和"有向量但指纹空"是两种完全不同的状态,混在一起就没法告诉用户该怎么修。
**建议**:三态而非两态,且**默认 fail-closed**(空指纹的带向量块按不可比处理,进 `no_fingerprint` 计数而不是静默放行)。
| 状态 | 条件 | 补救 |
|---|---|---|
| `no_vector` | vector 为空 | 回填(调 embedding 补算) |
| `no_fingerprint` | 有 vector、fp 为空 | 重嵌(无法判断属于哪个空间) |
| `stale` | 有 vector、fp 非空但 ≠ active | 重嵌到当前空间 |
| `active` | 有 vector、fp == active | 可召回 |
**已定(裁定,不再待拍板):fail-closed,且写侧直接拒收。**
> 理由:无名向量的块(有 `vector` 但 `fingerprint` 为空)**直接拒收**,比
> 「放过再靠下游兜」更能守住空间隔离。安全语义优先,**不为 HA 的疑似缺陷同构** ——
> HA 在 `block_recall.go:140` 的三个 `&&` 让空指纹块被放行,这是我们要显式修正的
> 行为,不是要复刻的行为。
落点分两层,缺一层都不算落实:
| 层 | 行为 | 位置 |
|---|---|---|
| **写侧(已实现)** | `putBlock` 收到 vector 但无 fp ⇒ **抛错**,块不入库 | `lib/store.js` 的 `putBlock` |
| **读侧(F 组)** | 万一库里有声明前的存量行(fp 空 + vector 非空)⇒ 交给下游兜,归 `unknownSpace` 计数,**不参与向量召回** | F2/F3 的过滤与计数 |
写侧拒收是主防线,读侧归类是存量兜底 —— 两者都要,因为「拒收新数据」管不了
「已经躺在库里的行」。
### 0.3 `graph_centroid` 的 DDL 与 HA 不一致,现在改是免费的
phase A 我建的表(`lib/store.js` 的 `DDL`):
```sql
CREATE TABLE IF NOT EXISTS graph_centroid (
fingerprint TEXT PRIMARY KEY,
dim INTEGER NOT NULL,
count INTEGER NOT NULL,
sum_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
```
HA 的表(`internal/memory/centroid_store.go:58-66`):
```sql
CREATE TABLE IF NOT EXISTS graph_centroid (
fingerprint TEXT PRIMARY KEY,
dim INTEGER NOT NULL,
vector_count INTEGER NOT NULL,
vector TEXT NOT NULL,
stats TEXT NOT NULL,
built_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
```
差别:我少一个 `stats` 列(HA 用它存 `centroidMeta`:`Anomalous`/`MeanLength`/`VectoredCount`/`BuiltAt`),列名也不同。`stats` 不是装饰——`LoadCentroid` 靠 `meta.Anomalous` 决定要不要启用校正(`centroid_store.go:205-209`),没有它就无法复现 HA 的判定。
**现在表是空的 ⇒ 改 DDL 零成本。一旦写进任何一行就要做迁移。**
**建议**:按 HA 的列对齐(`vector_count` / `vector` / `stats` / `built_at`),并在 `stats` 里存 `centroidMeta` 同构 JSON。phase A 那个 DDL 是我的疏忽——当时只求"表先建好省一次迁移",没对齐列。
### 0.4 向量化失败(网络断/超时)算不算失配?
decision 3 说的是**指纹失配**要可查询。但同一个"静默降级"问题在网络失败时也存在,且 HA 明写要静默(`internal/agent/core/toolcall.go:1271-1274`):
```go
// 向量化失败不报错到工具层:符号路仍可用,而这条失败通常意味着
// 模型未加载/超时,报给模型只会让它以为"记忆不存在"。
return ""
```
**建议**:同样的处理——不报错、退回符号路、**但进计数器**(`last_recall.mode = 'symbol_only'` + `embed_error` 原因)。理由与 decision 3 完全同构:静默可以,静默到无人知道不行。
> 需要你拍板:**embedding 调用失败是否一并纳入计数器口径**(我建议纳入)。
---
## 1. 判据总表
分组:`A` 前置重构 / `F` 指纹空间 / `V` 融合 / `C` 中心化 / `D` 降级与可见性 / `H` http provider / `G` 守卫。
每条格式:**断言 → 依据 → 变异(必须能红)**。
### A. 前置重构(没有这组,后面都写不了)
当前 `recallBlocks` 是**同步**函数(`lib/recall.js:223`)。向量化是异步的,所以:
| # | 断言 | 依据 |
|---|---|---|
| A1 | `recallBlocks` 保持同步,另新增异步 `recallBlocksHybrid`;phase A 的 26 个测试与 8 个 checkpoint **全部仍然通过**,一条不改语义 | 代码可核:`lib/recall.js:229` 是同步导出的 `recallBlocks`(函数体内 `await` 0 处),`lib/recall.js:613` 是 `async` 的 `recallBlocksHybrid` —— 异步只出现在新 wrapper |
| A2 | 未配置 embedder 时,`recallBlocks` 的返回与 phase A **逐字节相同**(同序、同分、同 `why`) | 无向量是合法配置,不是降级 |
**A1 变异**:把 `recallBlocks` 改回同步并在内部 await 一个 Promise ⇒ 测试红(语法/类型层面即失败)。
**A2 变异**:无 embedder 时仍给候选加一个常量向量分 ⇒ 顺序或分数与本快照不一致 ⇒ 红。
> ★ A2 的判据实现方式:把 phase A 的召回结果**固化成 golden fixture**(输入 query + 块集合 → 期望的 `[{id, score, exact, why}]`),phase B 之后跑同一个 fixture 必须完全一致。这是防止「加了向量顺手改了符号路排序」的唯一可靠办法。
### F. 指纹与向量空间
| # | 断言 | 依据 |
|---|---|---|
| F1 | 块写入时 `vector` 与 `fingerprint` **同事务同存**,且 fingerprint 取自 provider 自报(`Info().Fingerprint`)而非配置抄写 | HA 的 `PutMemoryBlocks` 把两者写在同一 `INSERT`(`block.go:203-300`);pub SPI 的 `Info.Fingerprint` 契约是「provider build 变了就必须变」(`pkg/embedding/embedding.go`) |
| F2 | 召回时 fp 失配的块**被跳过**(不抛错、不出现在结果里) | `block_recall.go:140-143` |
| F3 | 失配块**计数可见**,且经工具可查询 | **decision 3** |
| F4 | 维度失配与指纹失配**分开计数**,且**两个都能非零**:召回路径按 `fingerprint` 与 `dimension` 各过滤一次,分别产出 `skipped` / `skippedDim` | HA 分开:`skippedDim` 与 `skippedFP`(`block_recall.go:126` / `:136` / `:141`)。补救不同:维度失配 ⇒ 换 provider 配置;指纹失配 ⇒ 重嵌。<br>★ 恒为 0 的字段等于没做(参见下条) |
| F5 | 空 fingerprint 的带向量块**不被静默接受**:写侧 `putBlock` 直接拒收(抛错);读侧若遇存量行则归 `unknownSpace`,不参与向量召回 | **§0.2 已定:fail-closed**。HA 此处 fail-open(`block_recall.go:140` 三个 `&&`),我们**刻意不同构** —— 安全语义优先 |
| F6 | 指纹变更后旧块的三段行为,**一个用例内同时断言**(见下) | 对应 HA 的 `skippedFP` 语义 + 本插件「不删数据」原则 |
| F7 | 混合空间共存时,召回只用 active fp;store 级统计按 fp **分组**列出(含块数、维度、状态) | HA 的 `BlockVectorStats.Fingerprints` / `Dimensions`(`block_recall.go` 的 `BlockVectorStats` 结构) |
| F8 | 维度校验分两个入口,**都要**:<br>• **调用方显式传向量**(`putBlock` / `commit` 带 `vector`)且声明了 `expectedDimension` ⇒ **硬报错**,不落库<br>• **provider 返回的向量**宽度不对 ⇒ 降级为「不嵌入」(W4),不抛给用户 | `http_embedder.go:152` 与 `block_recall.go:135-138`("不截断、不填充") |
**变异**:
- M-F2:删掉 fp 判断 ⇒ 失配块出现在结果里 ⇒ 红
- M-F3:计数器恒返回 0 ⇒ 红
- M-F4:把 dim 失配并入 fp 计数 ⇒ 分别断言的用例红
- M-F4b:删掉召回路径的维度过滤 ⇒ `skippedDim` 恒 0、窄向量被召回 ⇒ 红
★ **恒 0 字段的教训**:`last_recall.skipped_dim` 第一版写死 `0`,注释是
「reported as 0 rather than guessed」,而唯一断言是 `typeof === 'number'`
—— 常量永远满足,等于把「F4 分开计数」做成摆设。这与更早那处
`assert.equal(hits.skipped, 2)` 是同一类:**判据咬不住的东西就是没做**。
裁定选 (a):召回路径补维度过滤,让该字段能非零;而不是把字段删掉。
- M-F5:删掉 `putBlock` 的 fp 守卫 ⇒ 无名向量的块被静默接受 ⇒ 红(写侧)
- M-F5b:读侧把空 fp 当匹配 ⇒ `unknownSpace` 计数为 0 且该块被召回 ⇒ 红(读侧)
- M-F6:召回不过滤旧块 ⇒ 「召回层」那半红;或把旧块删掉 ⇒ 「数据层」那半红
- M-F7:统计只报 active fp ⇒ 分组断言红
- M-F8:把写侧宽度检查改成恒不触发 ⇒ 坏向量落库 ⇒ 硬报错断言红
★ F8 的两个入口要分清,否则会互相打架:
- **调用方 bug**(显式传了一个宽度不对的向量):这是它自己的错,**必须抛**,
否则它会以「有块、可召回不了」的形式沉默很久。
- **provider 行为异常**(embedding 返回的宽度不对):用户没有做错任何事,
按 W4 降级为「不嵌入」,块仍写入。把 provider 故障也抛给用户,
等于让一次上游抖动变成写入失败。
#### 关于 `embedFailure`(记账,不是遗漏)
四态里 `noVector` / `unknownSpace` / `spaceMismatch` / `dimensionMismatch` 都是
**库状态**,从行派生(D4)。`embedFailure` 不进四态,因为它是**单次调用事件** ——
描述「这次没能拿到向量」,不是「库里存在这样的行」。
它有自己的位置:`recallBlocksHybrid` 的 `degraded`(值域
`no_embedder` / `embed_error` / `aborted` / `vector_ready_unused`),
由 W 组判据覆盖。把它塞进库状态计数会让「同一时刻读两次得到不同数字」,
那正是 D4 要避免的。
#### F6 的三段口径(裁定:选 (a),措辞不再并列成互斥)
F6 曾被写成「仍可按文本调出、不参与向量召回」—— 读起来像互斥,其实说的是三个不同层。
**「按文本调出」= store 级无空间浏览,不是生产召回路径**。
之所以不改 F2 去迁就 F6 的字面自洽:**F2 是 `fusion.go:495` 同款的安全缺陷修复,
优先级高于 F6 的措辞**。所以 F6 改成三段并列,一段一条断言,同一个用例里全断言:
| 层 | 断言 | 若只测这一层会漏什么 |
|---|---|---|
| **数据层** | 指纹变更后旧块**仍在库中**:块数不减,按 fp 分组两行都在 | 只测「过滤掉了」会漏掉「数据被误删」 |
| **浏览层** | store 级**不带空间参数**的文本查询仍能取到旧块 | 只测数据层证明不了它还可检索 |
| **召回层** | **带 fingerprint** 的生产召回路径必须把旧块过滤掉(由 F2 的判据承担) | 只测浏览层会把 F2 挡回去 |
⇒ 判据形态:一个用例内同时断言三件事。少断言任何一段,另外两段都可能在无人察觉时退化。
### V. 向量与符号的融合
现有打分分带(`lib/recall.js`,phase A):
```
[1.0+) exact 数字串布尔置顶
[0..1) lexical / word coverage / CJK coverage
```
phase B 必须把向量放进 **[0..1) 带**,不得越到 exact 带。
| # | 断言 | 依据 |
|---|---|---|
| V1 | 精确串命中仍**布尔置顶**,向量分只在同带内做平手微调(`1.0 + vec*0.01` 形态) | `fusion.go:427`;其上的注释记录了实测 bug:`sym=1.0×0.3=0.30` 输给 `vec=0.62×0.7+sym=0.333×0.3=0.534` |
| V2 | 非精确候选:`score = vec*0.7 + sym*0.3`(无向量时退化为纯 sym) | `defaultWeights()` = `{Vector: 0.7, Symbol: 0.3}`(`fusion.go:66`) |
| V3 | 向量分与符号分**可分别归因**(`vectorHit` / `symbolHit` / `why` 三元组),便于调试与模型解释 | `FusedHit` 的字段设计(`fusion.go:34-48` 的 `FusedHit`) |
| V4 | tier3(纯泛化描述)从「已知缺口」变为**通过**,且缺口断言随之更新 | phase A 的 V2 checkpoint 显式断言了 tier3/4 是缺口——加向量后必须翻转成通过断言,否则等于没落地 |
| V5 | 向量路召回但符号路零命中的块,**必须能上场**(不能被符号路截断掉) | `fuseCandidates` 明确注释:"符号路在全量块里找命中(不是只在向量候选里找)",并记录了 `&out[i]` 指针 bug(`fusion.go:367-374`) |
| V6 | 查询侧与文档侧用不同 `side`(`query` / `document`) | HA http 协议有 `side` 字段(`http_embedder.go:76`),`PurposeQuery`/`PurposeDocument` 是两个枚举 |
| V7 | 候选池在融合前**不截断**(先全量打分 → 融合 → 再按 topK 截断) | `RecallBlocksWithArbitration` 的顺序(`block_recall.go:229-255`),理由注释:「向量路已经把正确块挤掉的话,融合也无从救回」 |
**变异**:
- M-V1:把 exact 改成 `0.7*vec + 0.3*sym`(把布尔降成权重)⇒ V1 红。**这是最重要的一条变异**——它就是 HA 记录过的那个真实 bug
- M-V2:权重改成 0.5/0.5 ⇒ V2 的数值断言红
- M-V5:只在向量候选里找符号命中 ⇒ 符号救回的块消失 ⇒ 红
- M-V7:先按向量 topK 截断再融合 ⇒ 被挤掉的正确块回不来 ⇒ 红
- M-V6:两侧都用 `query` ⇒ 断言 side 的用例红
### C. 中心化(`graph_centroid`)
| # | 断言 | 依据 |
|---|---|---|
| C1 | `Anomalous` 判定阈值 = 均值范数 ≥ **0.6** | `meanThreshold = 0.6`(`centroid.go:87`),依据「均值范数 0.9374 时去均值让分离度提升 7 倍」 |
| C2 | 各向同性的库(`!Anomalous`)**不启用**校正 | `LoadCentroid` 的 `if !meta.Anomalous { return nil, false, nil }`(`centroid_store.go:205-209`) |
| C3 | 带向量块数相对建中心时变化 > **20%** ⇒ 判 stale | `centroidRebuildRatio = 0.20`(`centroid_store.go:55`) |
| C4 | 校正时**查询向量与块向量都去均值**,不能只处理一边 | `centerBoth`(`centroid_store.go:443-451`)+ 注释「只处理一边等于混两种量纲」 |
| C5 | 「不校正」的**每一种原因可区分**:无中心 / 维度不符 / 已过期 / 各向同性 | `LoadCentroid` 有 4 条 `return nil, false, ...` 分支,HA 全部只返回 `(nil,false,nil)`,调用方无法区分 |
| C6 | 中心按 fingerprint 分行存储,不跨空间复用 | 表 PK 是 `fingerprint` |
**变异**:
- M-C1:阈值改 0.5 或 0.9 ⇒ 边界用例红
- M-C4:只去查询向量的均值 ⇒ 同值对的余弦断言红
- M-C5:把 4 种原因合成一个 `available: false` ⇒ 区分断言红
> **C4/C5 的一条实现注意**:HA 在 Go 里必须「离开 RLock 之后重建」(`centroid_store.go:198-203` 记录了自死锁的实测)。本插件是 `node:sqlite` 同步 API + JS 单线程,没有那个死锁形态;但**重建是 O(n·d),不能放在每轮召回的热路径上**。断言写成"C3 判定 stale 的那一轮**仍返回结果**(用未校正向量),重建异步进行"——与 HA 的 `scheduleCentroidRebuild` 语义同构。
### D. 降级与可见性(decision 3 的落地)
| # | 断言 | 依据 |
|---|---|---|
| D1 | 召回主路径**永不**因向量问题抛错:无 embedder / 调用失败 / 全量 fp 失配 ⇒ 仍返回符号路结果 | decision 3 的「保留跳过不报错」 |
| D2 | 每一次跳过都进计数,**不留无计数的静默分支** | decision 3 的「不许只留日志」 |
| D3 | 计数可经**工具**查询(`stablemem_stats` 或 `stablemem_introspect`) | decision 3 的「暴露到工具面(可查询)」 |
| D4 | 计数从 **DB 派生**,不在内存里累加 | 内存累加重启即丢,且与真实库状态会漂移。HA 的 `BlockVectorStats` 就是扫库算的(`block_recall.go`) |
| D5 | 常驻索引行(`systemPrompt.context`)展示**不可召回比例**,让模型每轮都能看到 | 我在上一轮提的;复用已有展示位,不新增机制 |
| D6 | 计数分两层:**store 级**(存量状态,从 DB 派生)+ **上次召回级**(本次跳过了几条,per-call) | 两者回答不同问题:「库里有 40% 不可召回」vs「这次查询因失配丢了 3 条」 |
**D3 的工具输出形状(建议,待你确认字段名)**:
```json
{
"blocks": { "total": 120, "with_vector": 98, "no_vector": 22 },
"spaces": {
"active": { "fingerprint": "http:jina-v5:2048:a1b2c3d4", "dim": 2048 },
"by_fingerprint": [
{ "fingerprint": "http:chineseclip:512:9f8e7d6c", "dim": 512, "blocks": 40, "status": "stale" },
{ "fingerprint": "http:jina-v5:2048:a1b2c3d4", "dim": 2048, "blocks": 58, "status": "active" }
],
"no_fingerprint": 0
},
"centroid": { "state": "anomalous", "stale": false, "mean_length": 0.9374 },
"last_recall": { "mode": "hybrid", "skipped_fp": 3, "skipped_dim": 0, "embed_error": null }
}
```
关键点:`status` 直接告诉用户**该做什么**(`stale` ⇒ 重嵌;`no_vector` ⇒ 回填;`anomalous=false` ⇒ 不需要校正)。
**变异**:
- M-D1:全量 fp 失配时抛错 ⇒ 红
- M-D2:把某个跳过分支的计数删掉 ⇒ 该分支的用例红
- M-D3:工具不注册 `stablemem_stats` ⇒ 红
- M-D4:计数改成内存累加 ⇒ 重启后计数归零的用例红
- M-D6:只报 store 级不报 per-call ⇒ 红
### H. http provider(decision 1 的落地)
| # | 断言 | 依据 |
|---|---|---|
| H1 | 请求体字段与 HA 一致:`model` / `modality` / `side` / `text` / `data` / `mime` | `httpEmbedRequest`(`http_embedder.go:72-79`) |
| H2 | 响应兼容两种形状:顶层 `embedding`,或 `data[0].embedding` | `httpEmbedResponse`(`http_embedder.go:81-87`) |
| H3 | 非 2xx ⇒ 报错并进 `embed_error`,不吞成空向量 | HA 抛错(`http_embedder.go:141-142`),但上层 `recallByBlocks` 吞掉了;我们要「抛给召回层 + 召回层计降级」 |
| H4 | 超时(默认 30s)可配,超时按 H3 处理 | `http_embedder.go:94-97` |
| H5 | **零新依赖**:用 Node 内建 `fetch`,不加 onnx 相关包 | decision 1 |
| H6 | 响应体大小上限(HA 是 4 MiB) | `http_embedder.go` 的 `io.LimitReader(resp.Body, 4<<20)` |
**变异**:
- M-H1:字段名改成 `input` ⇒ 用录制响应的契约测试红
- M-H2:只支持顶层 `embedding` ⇒ `data[0]` 形状的用例红
- M-H3:非 2xx 返回空数组 ⇒ 红
### G. 守卫(不许伤到已交付的东西)
| # | 断言 |
|---|---|
| G1 | 整仓 `npm test` **225/225**、`npm run check` **12/12** 全绿(phase A 为其中的子集),语义不变(A1/A2 已覆盖) | 读数取自既有 V7 取证日志:`/tmp/v7-order-20261101-173334-t1..t3.log`(命令 `npm test`)各含 `# tests 225` / `# pass 225` / `# fail 0`;`/tmp/v7-order-20261101-173334-c1..c3.log`(命令 `npm run check`)各含 `12/12 checkpoints passed`。这些日志采集于提交 `591f340`,本提交不改数字口径 |
| G2 | 未配置 embedder ⇒ 进程内无任何 HTTP 出站、无新依赖被加载 |
| G3 | `stablemem_commit` 在 embedder 不可用时**仍能写入**(块入库、vector 留空,`no_vector` 计数 +1),不报错 |
| G4 | 不引入 media/媒体块(`docs/feasibility.md` §5.4 的决定不变) |
**G3 变异**:embedder 不可用时拒绝 commit ⇒ 红。依据是 HA 的「无 provider 时块仍入库,检索退化为符号路,**不报错不编造**」(`memory-restructure-plan.md` §三 第 2 步判据)。
---
### W. 实施期新增(H 组落地时发现的判据,草案里没有)
这组不在原草案里。它们是实现过程中被真 bug 逼出来的,每条都对应一次
「绿着但错」,所以留在这里作为历史与方法记录。
| # | 断言 | 逼出它的 bug | 变异 |
|---|---|---|---|
| W1/W5 | `exec.signal` 传到 `embedText`;abort 仍落盘(vector 空) | 写路径不接信号,取消后等满 adapter 超时 | 不传 signal ⇒ 红 |
| W4 | 写侧维度校验:长度不符按 embed 失败处理 | 坏向量会入库,召回时被 skippedDim 静默跳过 | 删校验 ⇒ 红 |
| W6 | 无 embedder 时 `commit` 与 `putBlock` 同形 | 九处调用点靠「无 await 也同步写完」的隐式事实 | — |
| W7 | 空指纹在 embed **之前**判 | 原顺序白付一次网络往返 | 改为事后判 ⇒ 红 |
| W8 | `rebuildCentroid` 从块重建(空间过滤 / 维度跳过 / 0.6 阈值) | `saveSpaceCentroid` 在 lib/ 里零调用点,中心表永久空 | 去空间过滤 / 去维度过滤 / 改阈值 ⇒ 红 |
| **W9** | 真 adapter 下 caller abort 打断真实请求 | **`embedText(text)` 单形参,第二实参被 JS 静默丢弃** | 单参 + 去 fuse ⇒ 红 |
| W9b | adapter 自身超时在有 caller signal 时仍生效 | 接 caller 信号时容易把自身 timeout 丢掉 | 只留 caller ⇒ 红 |
| **W10** | 读路径(pre-step → contextForStep → recall → adapter)也接信号 | **读路径完全没有取消**,每次 pre-step 可卡 30s | 删 `options.signal` ⇒ 红,30001ms |
| **W11** | 降级要在**渲染文本**里可见 | `degraded` 是 `enumerable:false`,对任何序列化都不可见 | 去掉提示行 ⇒ 红 |
| **W11d** | 降级 + 零命中仍要注入提示,且无条目 | **两处短路**(contextForStep 与 renderRecall)各自都能吃掉它 | 恢复任一短路 ⇒ `W11d` 红(两条独立变异 W11d† / W11d†ctx) |
| **W11e** | 无 embedder(出货默认)+ 零命中 ⇒ 注入必须为 `''` | **P0**:每轮白灌 ~213 字符恒定无条目的记忆头,训练模型跳过该段,废掉 W11 用意 | `no_embedder` 改回返回文案 ⇒ 红 |
| **W12** | `aborted` 与 `embed_error` 标签与文案必须分开 | 取消被描述成「搜索失败」,会诱导无谓重试 | 合并标签 ⇒ 红 |
| **W11f** | 零命中时任何模式都不许说「matches below」;有条目时必须指向条目(表驱动,覆盖未来未知模式) | **`aborted` 与 `default:` 兜底在零命中时仍承诺条目**;`default` 是未来任何新模式唯一会走的路,最不该编 | 删 `aborted` 分叉 ⇒ 红;删 `default:` 分叉 ⇒ 红 |
★ 这组里 W9/W10/W11d 的教训是同一个:**判据用替身/短路/自造形状时,
验的是替身而不是真代码**。W9 之前用假 embedder(它自己监听 abort),
W11d 之前两处短路互相掩盖,V3b 之前用自造 2 段 batch。
补判据时先问一句:这条路径上有没有替身站在可能出错的那段前面。
★★ **变异纪律(同型犯了四次:W11†、W11d†a、W11d†b、W12†)**:
变异体必须改 `lib/` 下的文件;一旦它动的是 `test/`、或断言的是测试里
自己拼的局部函数,它就不是变异,是自测。这类「变异」在真回退下**保持绿**,
却让台账按变异计数 —— 与 G5 那个恒假白名单同一种病:不能失败的守卫不是守卫。
现行做法:真改源码 → 带 cache-busting 参数重新 import → 断言行为翻转 →
还原文件;锚点匹配不到时**显式失败**,避免重构后静默失效。
## 2. 变异规则汇总
一句总纲:**每条断言至少一条变异能把它打红;打不红就说明这条判据没测到东西。**
必须能红的 12 条(按重要性):
| 变异 | 打红哪条 | 为什么最重要 |
|---|---|---|
| M-V1 精确串从布尔降成权重 | V1 | HA 真实踩过的 bug(0.300 输给 0.534) |
| M-F2 删 fp 判断 | F2 | 跨空间混用的直接入口 |
| M-D3 工具不注册 stats | D3 | decision 3 的红线 |
| M-A2 无 embedder 时仍加向量分 | A2 | 防止「加向量顺手改了符号路」 |
| M-D4 计数改内存累加 | D4 | 防止「重启就忘」 |
| M-C4 只去一边均值 | C4 | 混量纲,分数全错但不报错 |
| M-F5 空 fp 当匹配 | F5 | phase A 存量块的归类 |
| M-F4 dim 并入 fp 计数 | F4 | 补救措施报错等于没报 |
| M-V7 先截断再融合 | V7 | 正确块在融合前就被挤掉 |
| M-H3 非 2xx 吞成空向量 | H3 | 静默降级 |
| M-C5 合并 4 种不校正原因 | C5 | 用户不知道要不要修 |
| M-G3 embedder 不可用时拒绝写入 | G3 | 记忆能力不得因向量服务挂掉而消失 |
---
## 3. 已知会改动的接口(开工前要有心理准备)
| 改动 | 影响 |
|---|---|
| `recallBlocks` 同步 → `async` | 测试全部加 `await`;`contextForStep` 也变 async(handler 已是 async,无外部影响) |
| `StableMem.recall()` → async | 同上 |
| `putBlock` 增加 `vector` / `fingerprint` 列 | **需要给 `memory_blocks` 加两列**。表里已有数据(phase A 的块),要 `ALTER TABLE` 或重建 |
| `graph_centroid` DDL 改列名对齐 HA | 现在免费(表空),见 §0.3 |
| 新增 `stablemem_stats` 工具 | decision 3 的落点 |
> `memory_blocks` 加列这件事提示一个更深的问题:**phase A 的 DDL 已经是"已发布"的**。如果这个分支已经有人用了,加列必须走 `ALTER TABLE ... ADD COLUMN`(sqlite 支持且廉价),不能 `DROP` 重建。建议 phase B 第一件事就是加一个 `PRAGMA user_version` 的迁移框架——现在只有 0→1 一步,成本一天;等 L2 落地再补就贵了。
---
## 4. 不做的事(明确排除)
- 不背 ONNX(decision 1)
- 不做媒体块向量(`Modality` 仍只有 `text`)
- 不做 L2 冷文档向量(等 §5.2)
- 不做跨进程/跨 agent 的向量共享
- 不做向量压缩/量化(规模不到)
- 不改仲裁(`arbitrate`)——它在 phase A 都没移植,phase B 也不顺带加。理由:仲裁是**同属性多值的时序**问题,与向量无关,混进来会让本阶段的判据失焦
---
## 5. 工作量(在 §7.1 阶段 B 原估 3–4 人天上修)
| 项 | 人天 |
|---|---|
| A 组:异步化 + golden fixture 固化 | 0.5 |
| H 组:http provider + 契约测试(录制响应) | 1 |
| schema 迁移框架 + `memory_blocks` 加列 + `graph_centroid` 对齐 | 0.5 |
| V 组:融合 + 分带 | 1 |
| F/D 组:三态归类 + 计数器 + `stablemem_stats` 工具 | 1 |
| C 组:中心化 | 1 |
| 判据与变异 | 1 |
| **合计** | **6–7** |
上修原因:原估 3–4 人天没算 schema 迁移、`stablemem_stats` 工具(decision 3 新增)、以及异步化带来的全量测试改动。
---
## 6. 等你的三个回话
1. **§0.1** 指纹派生式:扩展(含 endpoint)还是与 HA 逐字同构?
2. **§0.2** 空 fingerprint:fail-open(同 HA)还是 fail-closed(我建议)?
3. **§0.4** embedding 调用失败是否一并进计数器(我建议进)?
外加一个不需要你拍板但我打算做的小修正:**§0.3 的 `graph_centroid` DDL 对齐 HA 列名**(现在是空的,免费)。
这三个定了我就按 A→H→G→D→V→F→C 的顺序写实现,每组写完立刻跑该组的变异。

259
docs/phase-b-decision.md Normal file
View File

@ -0,0 +1,259 @@
# Phase B 开工前决策建议(RD-1)
> 回应四项裁定 + 一个交叉点。**不含实现。**
> 前序:`docs/phase-b-criteria.md`(判据草案,`0dfa6dc`)
>
> 本文只回答两件事:
> 1. 你裁定的四条,我**逐条核对后确认可执行**,并标出各自的实现落点。
> 2. **你提示的最大回归风险,我认为可以消除**——不必靠"小心地改 26 个测试",而是靠接口切分让它不发生。这是本文的重点。
---
## 1. 先说最重要的一条:异步化的回归风险可以结构性消除
你指出「异步化改动会碰 `recallBlocks` 这条已发布的召回主路径,26 个测试全量要改,这是本阶段最大的回归风险,不是工时问题」。
**我核了自己的草案,这个判断建立在我一个不必要的设计选择上。改掉它,风险归零。**
### 1.1 问题出在哪
我的草案 A1 写的是「`recallBlocks` 改为 `async`」。这是我图省事——把"要拿向量"和"要打分"混在了一个函数里。但这两件事**根本不需要在同一个函数里**。
看现有实现的分层(`lib/recall.js:223`):
```
pool = FTS5 候选 + 包含兜底 ← 纯同步(读 sqlite)
scored = 对 pool 打分排序 ← 纯同步(纯函数,无 IO)
```
向量要插进来的位置只有一个:**给 pool 里的候选补一个 `vectorSim`**。补齐之后,打分逻辑完全不变,仍然是纯同步的。
### 1.2 所以切法应该是两段,不是一段
```
recallBlocks(store, query, opts) ← 保持同步,签名不变,行为不变
└─ 只做:pool 构建 + 打分 + 排序 + 截断
新增可选入参 opts.vectorScores = Map<blockId, number>
传了就参与打分,不传就是 phase A 的逐字节原行为
recallBlocksHybrid(store, embedder, query, opts) ← 新的 async 包装
└─ await embedder.embed(query) → 取候选池 → 补齐 vectorScores
→ 调用上面的同步 recallBlocks
```
好处是**每一条都是硬性质,不是"我注意点"**:
| 性质 | 为什么成立 |
|---|---|
| **phase A 的 `recallBlocks` 一行不改** | 它只是多了一个可选入参,默认 `undefined` |
| **9 个测试调用点一个不改** | 它们仍调同步版,仍拿同步返回值 |
| **golden fixture(A2)自动成立** | 不传 `vectorScores` 走的就是原代码路径 |
| **异步只出现在新函数里** | 旧的 API 面完全没被动过 |
| **26 个测试不需要"改",只需要"加"** | 现有断言保持原样,新断言追加 |
### 1.3 实测的调用面(这就是全部改动量)
我把所有调用点数了一遍:
```
生产代码:lib/index.js:124 return recallBlocks(...) → 改为 recallBlocksHybrid
lib/index.js:140 const hits = this.recall(query) → 加 await
lib/index.js:277 const injected = mem.contextForStep(...) → 加 await
测试代码:test/store.test.js 5 处调用 → 全不改(用同步版)
scripts/checkpoints.js 4 处调用 → 全不改(用同步版)
test/plugin.test.js:119 `mem.contextForStep = () => ''` → 不改
(async 化后 `await ''` 仍得 `''`,桩天然兼容)
```
**净改动:生产代码 3 行。测试代码 0 行。**
我实测确认了这个桩的兼容性——现有变异测试把 `contextForStep` 替换成同步返回 `''` 的函数,async 化之后 `await` 一个非 Promise 值就是它本身,所以那条变异测试**继续有效且不需要动**。
### 1.4 对 guard 组的建议
你要求「guard 组提到外部依赖之前,先证明异步化没改坏 phase A 语义」。我同意顺序,但建议把判据改成可结构验证的形式:
| # | 原草案 | 建议改成 |
|---|---|---|
| A1 | 「`recallBlocks` 改 async 后 phase A 仍通过」 | **「`recallBlocks` 保持同步,签名与实现均不变;同时存在 `recallBlocksHybrid`」**——用一条断言钉住"同步版没有被偷偷改" |
| A2 | 「无 embedder 时返回与 phase A 逐字节相同」 | 保留,且升级为 **golden fixture 对比**:固化 phase A 的 `[{id,score,exact,why}]`,phase B 跑同一 fixture 必须全等 |
附加一条新的守卫,这条比原 A1 更有价值:
| # | 断言 | 变异 |
|---|---|---|
| A3 | `recallBlocksHybrid` 在 **embedder 为 null 时**,其返回值与 `recallBlocks` 同参调用**逐字段相同**(含 `why` 字符串) | 让 hybrid 在无 embedder 时走一条略有差异的分支(哪怕只是 `why` 多个后缀)⇒ A3 红 |
A3 是真正防"两套实现漂移"的那条。因为切分之后存在两个入口,最大的新风险就是它们的行为不一致——A3 把它钉死。
### 1.5 结论
**你担心的 26 个测试全量改动,按这个切法不会发生。** 工时可从 6–7 人天里省回约 0.5 人天(原草案 A 组),但更重要的是:回归风险从"依赖执行纪律"变成"接口形状保证"。
我建议把这个切分直接写进判据草案的 A 组,替换掉原来的 A1。**需要你确认这个改法**——因为它改的是判据本身,不是实现细节。
---
## 2. 四条裁定的可执行性核对
### 2.1 指纹补 endpoint hash — 可执行,一处需明确
你定的格式:`http:<model>:<dim>:<sha256(endpoint)[:8]>`,并要求 endpoint 先规范化。
**可执行。** 落点在新的 embedder 窄接口后面,不在判据里出现 http 字段名(见 §3)。
需要你明确一个边界:**规范化具体包含哪些**?我准备按这四条,理由都是"同一服务写成不同字符串很常见":
| 规范化 | 例子 | 理由 |
|---|---|---|
| 去尾部 `/` | `http://h:8080/embed/` → `http://h:8080/embed` | 最常见的写法差异 |
| host 小写 | `http://HOST:8080` → `http://host:8080` | DNS 大小写不敏感,但字符串比较敏感 |
| 省略默认端口 | `http://h:80/embed` → `http://h/embed` | 同义 |
| 去首尾空白 | — | 配置粘贴常见 |
**不做**:不解析/不重排 query string、不做路径归一(`/a/../b`)。理由:前者会掩盖真实差异,后者的实现成本与收益不成比例。
若你认可,我把这四条写成 F 组的一条判据(每条一个用例 + 一个变异:去掉该条规范化则指纹变化 ⇒ 红)。
### 2.2 空指纹 fail-closed + 四态 — 可执行,但有一处需要你知道
你定的四态:无向量 / 指纹空 / 指纹不符 / 向量化失败。**可执行,且比我草案的三态更准**——我把"向量化失败"漏在了三态之外(草案 §0.4 还单独问你),你直接合进来是对的,因为它同样是"用户下一步动作不同"。
需要你知道的一点:**这四态的扫描成本**。
`BlockVectorStats` 在 HA 里是**扫全表**算的(`block_recall.go` 的 `BlockVectorStats` 全程 `SELECT vector, fingerprint FROM memory_blocks` 再逐行 `decodeVector`)。在 dsh 里如果 `stablemem_stats` 每次都这么扫:
| 块数 | 估算(含 JSON 解析向量) |
|---|---|
| 1k | 可忽略 |
| 10k | 首次调明显卡顿(HA 注释里 1391 块已"可接受但不优化") |
| 100k | 不可接受 |
HA 的实现里有个细节值得注意:它为了算 `Dimensions` 必须逐行 `decodeVector`(`block_recall.go:303`,`:313` 的 `dimSet[len(vec)]`)。**维度统计与"不解析"是互斥的**,这点我上一版说漏了。
**建议的取舍**:
| 输出项 | 需要解析向量? | 默认 |
|---|---|---|
| `total` / `no_vector` | 否(`length(vector) > 0`) | ✔ 报 |
| `no_fingerprint` / `stale` / `by_fingerprint.blocks` | 否(只看 `fingerprint` 列) | ✔ 报 |
| `by_fingerprint.dim` | **是**(或读 `graph_centroid.dim`) | 从 agent 中心表读,不扫块 |
| `vector_dimensions` 直方图 | **是** | 仅 `verbose: true` |
即:默认路径只扫 `fingerprint` + `length(vector)`,从 `graph_centroid` 取维度(中心表按 fp 一行,天然带 `dim`)。只有在 `verbose` 下才逐行解析来发现"同一 fp 下维度不一致"这种异常。
这样默认扫描是 O(n)、无 JSON 解析;10k 块约几十毫秒。
> D7:`stablemem_stats` 的**默认**路径不解析向量 JSON(用 `length(vector)` 判有/无,维度从 `graph_centroid` 取);`verbose: true` 才逐行解析。
> **变异 1**:默认路径改为 `decodeVector` ⇒ 断言 10k 块 fixture 的执行时间上界(或直接注入一个 decodeVector 计数器断其为 0)⇒ 红
> **变异 2**:从中心表取维度改成扫块 ⇒ 无中心时(第一次跑 stats)报维度为空 ⇒ 红
顺带一条边界:`graph_centroid` 可能为空(还没建中心)。此时 `by_fingerprint.dim` 报 `null` 而不是 `0`——`0` 会被误读成"维度是 0"。
### 2.3 `graph_centroid` 补 `stats` 列 — 已改为 HA 同构
这条是我草案 §0.3 自己承认的疏漏。对齐后的 DDL(`vector_count` / `vector` / `stats` / `built_at`)在 `stats` 里存 `centroidMeta` 同构 JSON:`{fingerprint, dim, block_count, vectored_count, mean_length, anomalous, built_at}`。
`anomalous` 必须在表里,因为 `LoadCentroid` 靠它决定是否启用校正(`centroid_store.go:205-209`)——没有它就无法复现判定。
**已按你的要求列为 phase B 第一件事。**
### 2.4 embedding 失败降级到纯 FTS5 — 可执行,且与我草案一致
草案 §0.4 和 D1 就是这个口径("召回主路径永不因向量问题抛错")。你裁定后它从"待拍板"变成"已定",我把它从 §0.4 的疑问项移到 D 组的硬判据。
一个补充:**降级也要带原因**。D 组的 `last_recall.mode` 我准备写成三值而非 bool:
| mode | 含义 |
|---|---|
| `hybrid` | 向量 + 符号都参与了 |
| `symbol_only` | 走了符号路(原因见 `degraded_reason`) |
| `symbol_only` + `degraded_reason: 'no_embedder'` | 没配向量 |
| `symbol_only` + `degraded_reason: 'embed_error'` | 配了但调失败 |
| `symbol_only` + `degraded_reason: 'all_mismatched'` | 配了、调成功,但库里没有可比块 |
这三种 `symbol_only` 对用户的意义不同:没配 = 没启用;调失败 = 服务挂了;全失配 = 该重嵌。合成一个 bool 就又回到"降级不可见"了。
---
## 3. 关于「向量来源可晚点定」——我建议现在就把接口焊死
你说向量来源可以晚点定,只要把 embedder 收在单方法窄接口后面、判据里不出现 http 专属字段名。**同意,并且建议再收紧一点。**
```js
/**
* 唯一的向量能力接口。整个插件只依赖这三个成员。
* @typedef {object} Embedder
* @property {(text: string) => Promise<number[]>} embedText 查询侧
* @property {() => { fingerprint: string, dimension: number }} info
* @property {() => Promise<void>} [close]
*/
```
三点说明:
1. **`embedText` 只有 text**,没有 media。phase B 的 `Modality` 仍只有 `text`(草案 §4 已排除媒体块),所以接口不需要 reserve 媒介参数。等到真做媒体时再加,比现在猜一个签名好。
2. **`info()` 同步返回** fingerprint 与 dimension。这让"维度校验"能在**调用 embedText 之前**做掉一部分(配置维度不符可以立刻硬报错,不用等一次网络往返)。
3. 判据里凡是断言 http 契约的(H 组 H1/H2/H6),**全部标成"provider 适配层的契约测试"**,与 V/F/C/D 组解耦。这样换本地 ONNX 时,H 组整组替换,V/F/C/D 不动。
**我建议加一条守卫**(G 组):
> G5:除 `lib/embed/http.js` 这一个文件外,`lib/` 下任何文件**不得出现** `http`、`endpoint`、`fetch`、`Bearer`、`model` 等 provider 专属标识。
> **实现方式**:一条源码 grep 断言(像 HA 的 `grep -rn '"agentcli"\|"terminal"'` 那条内核插件名判据一样)。
> **变异**:在 `recall.js` 里写一句 `import ... from './embed/http.js'` 之外的 http 细节 ⇒ 红。
这条比"约定不出现"强,因为它是机器可查的。
---
## 4. 交叉点:6–7 人天要不要投
**我的建议:投,但要按 §1 的切法重新算,实际是 5.5–6.5 人天。**
| 项 | 原草案 | 按 §1 切分后 |
|---|---|---|
| A 组 异步化 + golden fixture | 0.5 | 0.5(fixture 仍要做,但异步化的改动量降到 3 行) |
| H 组 http provider + 契约测试 | 1 | 1 |
| schema 迁移框架 + 加列 + centroid 对齐 | 0.5 | 0.5 |
| V 组 融合 + 分带 | 1 | 1 |
| F/D 组 四态归类 + 计数器 + stats 工具 | 1 | 1(D7 的 `length(vector)` + 中心表取维度不额外加时) |
| C 组 中心化 | 1 | 1 |
| 判据与变异 | 1 | 1(A3/A5 两条新增,被 §1 省下的测试改动抵消) |
| **合计** | **6–7** | **5.5–6.5** |
**支持投的理由**(你关心的不是工时,我按同等分量给反对的可能):
- phase A 交付的是"能存的记忆",phase B 交付的是"能找得到的记忆"。只存不找,这个插件的价值边界就是"精确串查询"——那其实用一个 FTS 索引就够了,不需要块、边、中心化这一整套。
- 已定死的四条裁定(endpoint 指纹、四态、centroid 列、失败降级)**都是为 phase B 才存在的**。不投这 6 天,这四条裁定就悬空。
- 增量风险可控:§1 之后,phase A 的代码路径基本没被动,新逻辑集中在新文件。
**反对该投的理由**(我认为不足以否掉,但列出来):
- phase A 至今没有真实使用数据。6 天投下去,如果实际没人用 `stablemem_commit`,向量侧就是空转。**这一条我认为是最值得你先确认的**——是否有真实使用中的 dsh 会话在产生块?
- tier3(纯泛化描述)在 HA 里即使有向量也只是"目标块排名 389/2686"(`docs/zh/recall-capability-tiers.md`)。也就是说 phase B 交付后,tier3 从"完全不可用"变成"勉强可用",不是"好用"。这是能力上限,不是实现问题。
如果你的判断是"先看有没有人用",那我建议一个更便宜的中间态:**只做 H 组 + schema + F 组(向量写入与四态可见),不做 V/C 组(不参与召回)**。约 2.5 人天,拿到"块有向量、统计能看、空间切换可见",但不改召回主路径。等有使用数据再上 V/C。**这条中间路我愿意做,但它不满足你已裁定的"phase B"目标**,所以需要你明确。
---
## 5. 需要你回话的(4 项)
| # | 事项 | 我的建议 | 不定的后果 |
|---|---|---|---|
| 1 | **§1 的接口切分**(`recallBlocks` 保持同步 + 新增 `recallBlocksHybrid`),替换草案 A1 | 采纳 | 按原 A1 做,26 个测试全量改动,回归风险回到"靠纪律" |
| 2 | **§2.1 endpoint 规范化的四条边界** | 采纳四条,不做 query 重排与路径归一 | 指纹对"同一服务不同写法"过敏,触发无谓全量重嵌 |
| 3 | **§2.2 D7**(stats 默认不解析向量,维度取自中心表) | 采纳 | 10k 块以上 stats 工具变卡;或将来被迫改接口 |
| 4 | **§4 交叉点**:投 5.5–6.5 做完整 phase B,还是投 2.5 做中间态(写入+可见,不改召回) | 完整 phase B;但**建议你先确认是否有真实使用中的会话在产生块** | 无 |
另有三项**不需要你回话、我按已定裁定执行**:§2.3 centroid 列对齐(你已裁定)、§2.4 失败降级(你已裁定)、§3 的 G5 源码 grep 守卫(我建议加,属实现细节)。
---
## 6. 我这一轮的自我修正记录
写这份建议时发现自己草案里两处该改的地方,一并记下,避免下一轮重复:
1. **A1 把"异步化"和"保持兼容"绑在一起了**(§1)。这是设计偷懒,不是必要复杂度。切分之后两者解耦。
2. **§0.4 把"embedding 失败"当成待拍板项**(§2.2)。你的四点裁定里直接把它并进四态说明它本来就不该单独问——它和"指纹失配"是同一类问题(静默降级的可见性),归类是我做窄了。
已完成的一条:§0.3 的 centroid DDL 疏漏是我自己发现的,已按你要求列为第一件事。

244
lib/embed/http.js Normal file
View File

@ -0,0 +1,244 @@
/**
* HTTP embedding adapter — the ONLY file allowed to know a transport.
*
* Group G's `G5` guard enforces that: no other file under `lib/` may contain
* `fetch(`, `Authorization`, `Bearer`, `http(s)://`, `model_dir`, or `api_key`.
* Keeping the transport confined here is what makes "swap HTTP for a local ONNX
* runner" a one-file change instead of an audit of the whole plugin.
*
* ## Protocol
*
* Deliberately identical to HologramAgent's `http` provider
* (`internal/memory/vector/http_embedder.go:72-87`) so an existing HA sidecar can
* be pointed at without a shim:
*
* ```jsonc
* // request
* { "model": "...", "modality": "text", "side": "query",
* "text": "...", "data": "<base64>", "mime": "image/png" }
* // response — either shape is accepted
* { "embedding": [ ... ] }
* { "data": [ { "embedding": [ ... ] } ] }
* ```
*
* ## Fingerprint
*
* `http:<model>:<dim>:<sha256(normalised endpoint)[:8]>`
*
* HA derives `http:<model>:<dim>` (`http_embedder.go:99`) — model and dimension
* only. That is unsafe once an endpoint is configurable: two deployments of the
* same model name at the same dimension produce **the same fingerprint but
* different vectors** (different weights, different hardware, a different
* region), so blocks from both spaces compare silently and wrongly. Adding the
* endpoint hash closes that hole. The digest is truncated to 8 hex chars: it
* only needs to distinguish a handful of configured endpoints, not to resist an
* attacker.
*
* Normalisation happens **before** hashing so that cosmetic differences in the
* configuration string do not each trigger a full re-embed:
*
* - strip surrounding whitespace
* - lowercase the scheme and the host
* - drop a trailing `/` from the path
* - drop an explicit default port (`:80` for http, `:443` for https)
*
* It deliberately does NOT reorder query strings or resolve `..` in paths:
* those differences are usually real, and hiding them would mask a genuine
* misconfiguration.
*
* @module dsh-memory/embed/http
*/
import { createHash } from 'node:crypto'
/** Request timeout; HA's default is 30s (`http_embedder.go:95-97`). */
const DEFAULT_TIMEOUT_MS = 30_000
/** Response body cap. HA uses 4 MiB (`http_embedder.go:136`). */
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024
/**
* Normalise an endpoint string for fingerprinting.
*
* @param {string} endpoint
* @returns {string} the normalised form (empty string when input is unusable)
*/
export function normalizeEndpoint(endpoint) {
const raw = String(endpoint ?? '').trim()
if (raw === '') return ''
let url
try {
url = new URL(raw)
} catch {
// Not a parseable URL: fall back to the trimmed string so the fingerprint is
// still stable, and let the request itself fail loudly later.
return raw
}
// URL already lowercases protocol and host; be explicit about it so the
// behaviour survives a switch away from WHATWG URL.
const scheme = url.protocol.toLowerCase()
const host = url.hostname.toLowerCase()
const defaultPort = scheme === 'https:' ? '443' : scheme === 'http:' ? '80' : ''
const port = url.port === defaultPort ? '' : url.port
const path = url.pathname.replace(/\/+$/, '')
const search = url.search
return `${scheme}//${host}${port === '' ? '' : `:${port}`}${path}${search}`
}
/**
* Derive the vector-space fingerprint.
*
* @param {object} input
* @param {string} input.endpoint configured endpoint (normalised internally)
* @param {string} [input.model]
* @param {number} input.dimension
* @param {string} [input.fingerprint] explicit override (HA has the same escape hatch)
* @returns {string}
*/
export function deriveFingerprint({ endpoint, model = '', dimension, fingerprint }) {
if (typeof fingerprint === 'string' && fingerprint !== '') return fingerprint
const normalised = normalizeEndpoint(endpoint)
const digest = createHash('sha256').update(normalised, 'utf8').digest('hex').slice(0, 8)
return `http:${model}:${dimension}:${digest}`
}
/**
* Build an embedder over an HTTP endpoint.
*
* Returns the same three-member shape `recallBlocksHybrid` expects — nothing
* here leaks into scoring, storage, or grouping.
*
* @param {object} config
* @param {string} config.endpoint
* @param {number} config.dimension
* @param {string} [config.model]
* @param {string} [config.apiKey]
* @param {string} [config.fingerprint] override
* @param {number} [config.timeoutMs]
* @param {'query'|'document'} [config.side] prompt side for TEXT embedding
* (default `query`).
*
* ★ V6 GAP — TEXT + `document` is INTERFACE-ONLY.
* Go assigns `Side: "query"` for text (`internal/memory/vector/http_embedder.go:105`)
* and `Side: "document"` only for images (`:109`). It never sends text with
* `document`, so there is no kernel precedent for what a text-document vector
* means, and no criterion here claims one. This option pins the WIRE SHAPE the
* adapter can produce; the SEMANTICS are deliberately unasserted.
* @returns {{ embedText: (text: string, signal?: AbortSignal) => Promise<number[]>,
* info: () => {fingerprint: string, dimension: number},
* close: () => Promise<void> }}
*/
export function createHttpEmbedder(config) {
const {
endpoint,
dimension,
model = '',
apiKey = '',
fingerprint,
timeoutMs = DEFAULT_TIMEOUT_MS,
side = 'query',
} = config ?? {}
// Only the two sides Go defines are accepted (`pkg/embedding/embedding.go:34-35`).
// Anything else would be an invented protocol value the provider cannot map.
if (side !== 'query' && side !== 'document') {
throw new Error(`dsh-memory: embed side must be 'query' or 'document', got ${String(side)}`)
}
if (typeof endpoint !== 'string' || endpoint.trim() === '') {
throw new Error('dsh-memory: http embedder requires an endpoint')
}
if (!Number.isInteger(dimension) || dimension <= 0) {
throw new Error(`dsh-memory: http embedder requires a positive integer dimension, got ${dimension}`)
}
const normalisedEndpoint = normalizeEndpoint(endpoint)
// ★ Computed ONCE and reused. The same string must reach both
// `memory_blocks.fingerprint` and `graph_centroid.fingerprint`; deriving it
// per call risks two tables holding two spellings, which makes
// `LoadCentroid` miss silently (no error, centering just never applies).
const fp = deriveFingerprint({ endpoint: normalisedEndpoint, model, dimension, fingerprint })
let closed = false
const info = () => ({ fingerprint: fp, dimension })
async function embedText(text, signal) {
if (closed) throw new Error('dsh-memory: http embedder closed')
// ★ TWO cancellations must both apply, and neither may replace the other:
// · the caller's (a cancelled turn must stop immediately), and
// · this adapter's own timeout (a wedged provider must still be bounded).
// `AbortSignal.any` fuses them; the repo already requires Node >= 22.5, where
// it is available. Writing a hand-rolled fuse here would be a second place to
// get the "one aborted before we start listening" case wrong.
//
// The previous version took `text` only, so the caller's signal was silently
// dropped by JS — the call ran to the adapter's own timeout even though the
// user had already cancelled. A dropped arrow is worse than no arrow: the
// call site reads as if cancellation were handled.
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(new Error('dsh-memory: embed request timed out')), timeoutMs)
const fused =
signal === undefined || signal === null
? controller.signal
: AbortSignal.any([signal, controller.signal])
try {
const response = await fetch(normalisedEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey === '' ? {} : { Authorization: `Bearer ${apiKey}` }),
},
body: JSON.stringify({
model,
modality: 'text',
// go: http_embedder.go:105 (`query`) / :109 (`document`, image only).
// The value travels verbatim; V6 pins the shape, not the semantics.
side,
text: String(text ?? ''),
}),
signal: fused,
})
if (!response.ok) {
const body = await response.text().catch(() => '')
throw new Error(`dsh-memory: embed endpoint returned ${response.status}: ${body.slice(0, 200)}`)
}
const raw = await response.text()
if (raw.length > MAX_RESPONSE_BYTES) {
throw new Error(`dsh-memory: embed response exceeds ${MAX_RESPONSE_BYTES} bytes`)
}
const payload = JSON.parse(raw)
// Both shapes are accepted (HA: http_embedder.go:81-87).
const vector = Array.isArray(payload?.embedding)
? payload.embedding
: Array.isArray(payload?.data?.[0]?.embedding)
? payload.data[0].embedding
: undefined
if (!Array.isArray(vector)) {
throw new Error('dsh-memory: embed response carries no embedding array')
}
if (vector.length !== dimension) {
// Hard error, never truncate or pad: a silently mis-sized vector is a
// meaningless score that looks plausible (HA: block_recall.go:135-138).
throw new Error(`dsh-memory: embed dimension ${vector.length}, want ${dimension}`)
}
return vector
} finally {
clearTimeout(timer)
}
}
return {
embedText,
info,
async close() {
closed = true
},
}
}

1148
lib/index.js Normal file

File diff suppressed because it is too large Load Diff

173
lib/loop.js Normal file
View File

@ -0,0 +1,173 @@
/**
* HologramAgent loop: surface recall as a SYSTEM section instead of history.
*
* Why this exists
* ---------------
* `dsh-memory` injects per-turn recall through the `agent/pre-step` waterfall,
* and that hook can only return `messages`. Whatever it adds 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 either: that hook
* runs before the turn text is known (documented in dsh-memory's own module
* note), so it can only render input-INDEPENDENT text.
*
* The loop's own `preStep` is the first place that has BOTH the assembled
* prompt and the claimed messages for this turn. So the swap point is here:
* let the existing pre-step hooks run unchanged, then move the recall block out
* of `messages` and into `assembly.sections`. The memory becomes part of the
* system prompt for that turn, and the user message never enters history.
*
* Coupling
* --------
* None, deliberately. We do not import the memory plugin or match its name; we
* recognise the *shape* every snapshot-style injector already uses
* (`source.sections = [{ name, text }]`), which is the same shape
* `dsh-time-context` emits. Anything a plugin chose to label that way is
* promoted; everything else passes through untouched.
*/
import { AgentLoop } from '@deepseek-ai/dsh-agent-loop'
/** Suffix appended to the promoted section name, keeping provenance visible. */
const PROMOTED_SUFFIX = ':system'
/**
* Split one message's `source.sections` out of the message itself.
*
* A snapshot carrier is recognised by ONE of two canonical spellings, both of
* which real producers emit:
*
* 1. `content: []` — a bare carrier. The sections are the whole payload.
* 2. `content: [{ type: 'text', text }]` — what this plugin itself emits
* (`lib/index.js:932`): one text block whose text is the sections' text
* joined by `\n\n` (or, from other producers, concatenated with no
* separator). Both conventions are accepted because the joining choice
* belongs to the producer, not to this loop.
*
* ★ The invariant is "move the text, do not rewrite it". So when content is
* present, it is promoted ONLY if the rendered blocks equal the sections'
* text under one of those two conventions, and the promoted section text is
* the *rendered content*, not a re-join of the sections. That makes
* "relocation" assertable in a test (`promoted.text === original content
* text`) and leaves no room for a silent edit. Anything else — a differing
* text, a non-text block, a runtime-context snapshot whose content carries a
* fixed preamble the sections do not — stays in history untouched.
*
* History: the previous rule was "content non-empty ⇒ reject", derived from an
* assumption ("content means the plugin wants the user to see it") that is
* false for this plugin's own output, so on the real path the promotion could
* never fire. See `test/loop.test.js` S1–S7.
*
* @param {object} message a user message from a pre-step decision
* @returns {{text: string, name: string} | null}
*/
function promotedSection(message) {
const source = message === null || typeof message !== 'object' ? undefined : message.source
if (source === null || typeof source !== 'object') return null
if (source.form !== 'snapshot') return null
if (!Array.isArray(source.sections) || source.sections.length === 0) return null
const parts = []
for (const section of source.sections) {
if (section !== null && typeof section === 'object' && typeof section.text === 'string' && section.text !== '') {
parts.push(section.text)
}
else if (typeof section === 'string' && section !== '') parts.push(section)
}
if (parts.length === 0) return null
const name = typeof source.kind === 'string' && source.kind !== '' ? source.kind : 'memory'
const blocks = Array.isArray(message.content) ? message.content : []
if (blocks.length === 0) return { text: parts.join('\n\n'), name }
// Any non-text block means this is not a carrier of prompt text: an image
// (or a block whose text is not a string) would be silently destroyed by a
// text-only move, so refuse the whole message.
for (const block of blocks) {
if (block === null || typeof block !== 'object') return null
if (block.type !== 'text' || typeof block.text !== 'string') return null
}
// Both canonical joins are accepted; the promoted text is the RENDERED
// content, which is what the model would have read, byte for byte.
const rendered = blocks.map((block) => block.text).join('')
const glued = parts.join('')
const spaced = parts.join('\n\n')
if (rendered !== glued && rendered !== spaced) return null
return { text: rendered, name }
}
/**
* Rewrite one pre-step decision so snapshot sections live in the prompt.
*
* The returned object is a NEW decision: `super.step()` keeps the one it
* received, and the loop's phase bookkeeping reads `assembly` off whatever it
* is handed. Mutating in place would work today and would silently break the
* first time the loop reuses a decision.
*
* @param {object} decision the decision returned by the pre-step waterfall
* @returns {object} the same decision shape with promoted sections moved
*/
export function promoteSnapshotSections(decision) {
if (decision === null || typeof decision !== 'object') return decision
if (decision.kind === 'reject') return decision
const messages = Array.isArray(decision.messages) ? decision.messages : []
const assembly = decision.assembly
if (assembly === null || typeof assembly !== 'object' || !Array.isArray(assembly.sections)) return decision
const kept = []
const promoted = []
for (const message of messages) {
const section = promotedSection(message)
if (section === null) kept.push(message)
else promoted.push(section)
}
if (promoted.length === 0) return decision
const sections = [...assembly.sections]
const seen = new Set(sections.map((section) => section.name))
for (const section of promoted) {
// A name collision means some other producer already owns that slot. The
// prompt layer throws on duplicates, so suffix rather than overwrite:
// dropping the content would hide memory with no visible failure.
let name = `${section.name}${PROMOTED_SUFFIX}`
for (let n = 2; seen.has(name); n += 1) name = `${section.name}${PROMOTED_SUFFIX}${n}`
seen.add(name)
// `interpolate: false` is mandatory, not cosmetic: recalled notes are free
// text and may legitimately contain `{{...}}`, which the strict interpolator
// rejects with `unknown prompt variable` (measured — dsh-memory hit this).
sections.push({ name, text: section.text, interpolate: false })
}
return { ...decision, messages: kept, assembly: { ...assembly, sections } }
}
/**
* The headless AgentLoop, with snapshot sections promoted into the prompt.
*
* No constructor and no static `inject`: the parent already registers the
* turnBoundary + inbox projections, calls `agents.setFactory(this)`, and
* declares the `{{provider}}` / `{{model}}` variables. Re-declaring any of them
* here would be the bug — `setFactory` throws if a factory is already
* registered, so a second registration is exactly what breaks startup.
*/
export class HologramAgentLoop extends AgentLoop {
/**
* Prepare one agent, then wrap its pre-step seam.
*
* Patching the *instance* (not the prototype) is deliberate: the prototype is
* shared with every agent the base class creates, including ones this plugin
* must not touch. An own property shadows it for this agent only.
*
* @param {...unknown} args forwarded verbatim to the parent
* @returns {{agent: object, signal: AbortSignal, publish: Function}}
*/
prepare(...args) {
const prepared = super.prepare(...args)
const agent = prepared === null || typeof prepared !== 'object' ? undefined : prepared.agent
if (agent === undefined || typeof agent.preStep !== 'function') return prepared
const original = agent.preStep.bind(agent)
agent.preStep = async (target, position) => promoteSnapshotSections(await original(target, position))
return prepared
}
}
export default HologramAgentLoop

View File

@ -0,0 +1,140 @@
/**
* Folds one model attempt into one compact stream plus ordered transient frames.
*
* Reproduced from `dsh-agent-loop/lib/types/index.js:352-473` (0.2.1-rc.2, MIT)
* because `dsh-agent-loop` does not export it, and there is no behaviour to
* change here: the framing has to match what `dsh-headless` and the session
* title plugin read off `agent/assistant-stream` byte for byte.
*
* @module dsh-memory/lib/loop/assistant-stream
*/
import { AssistantStreamAccumulator, BlockAssembler, LlmAttemptId } from '@deepseek-ai/dsh-llm'
export class AssistantStreamAttempt {
/** @type {() => number} */
nextRevision
/** @type {number} */
turn
/** @type {number} */
step
/** @type {(frame: object) => void} */
emit
accumulator = new AssistantStreamAccumulator()
assembler = new BlockAssembler()
index = 0
terminal = false
/** Attempt identity unique within this Agent lifecycle. */
attemptId
/** Whether this started attempt has emitted its terminal frame. */
get ended() {
return this.terminal
}
/**
* @param {string} sessionId identity embedded only in the Agent-lifecycle-local attempt id
* @param {number} attempt attached-Session-local attempt counter
* @param {() => number} nextRevision allocates the next emitted frame revision
* @param {number} turn durable turn owning the request
* @param {number} step durable step owning the request
* @param {(frame: object) => void} emit agent-scoped notification publisher
*/
constructor(sessionId, attempt, nextRevision, turn, step, emit) {
this.nextRevision = nextRevision
this.turn = turn
this.step = step
this.emit = emit
this.attemptId = LlmAttemptId(`${sessionId}:${attempt}`)
}
/** Publish the opening marker before the first delivered chunk. */
start() {
this.emit({
type: 'start',
attemptId: this.attemptId,
revision: this.nextRevision(),
turn: this.turn,
step: this.step,
})
}
/** Snapshot one chunk once, then feed durable compaction, assembly, and live publication. */
push(chunk) {
const timed = this.accumulator.push({ time: Date.now(), chunk })
this.assembler.push(timed.chunk)
this.emit({
type: 'chunk',
attemptId: this.attemptId,
revision: this.nextRevision(),
index: this.index++,
time: timed.time,
chunk: timed.chunk,
})
}
/**
* Publish terminal settlement after the matching durable event commits.
* @param {string} eventType durable settlement type
* @param {() => number} append synchronous durable append returning its committed seq
*/
settle(eventType, append) {
let seq
try {
seq = append()
} catch (error) {
this.abandon()
throw error
}
this.terminal = true
this.emit({
type: 'end',
attemptId: this.attemptId,
revision: this.nextRevision(),
index: this.index,
outcome: { kind: 'committed', eventType, seq },
})
}
/** Publish abandonment when no durable attempt event can be committed. */
abandon() {
this.terminal = true
this.emit({
type: 'end',
attemptId: this.attemptId,
revision: this.nextRevision(),
index: this.index,
outcome: { kind: 'abandoned' },
})
}
/** Exact compact stream for the final durable event. */
get stream() {
return [...this.accumulator.snapshot()]
}
/** Canonical completed-message blocks from the same chunks. */
blocks() {
return this.assembler.blocks()
}
/** Safe visible prefix when cancellation interrupts the attempt. */
interruptedBlocks() {
return this.assembler.interruptedBlocks()
}
/** Latest adapter-reported usage in the stream. */
get usage() {
return this.assembler.usage
}
/** Terminal reason, defaulting to stop when the stream omitted one. */
get finish() {
return this.assembler.finish
}
/** Replay state carried by the terminal finish record. */
get replayState() {
return this.assembler.replayState
}
}

255
lib/loop/inbox.js Normal file
View File

@ -0,0 +1,255 @@
/**
* Durable agent-inbox projection and its command facade.
*
* ## Why this file exists at all
*
* `dsh-agent-loop` keeps `inboxProjectionDefinition` module-private: its
* package entry exports only `AgentLoop`, `DEFAULT_MAX_PARALLEL_TOOL_CALLS`,
* `CONFIGURED_AGENT_IDENTITIES_KEY` and `turnBoundaryProjectionDefinition`
* (dsh-agent-loop/lib/index.js:2023). A replacement loop therefore cannot
* import the fold, and re-deriving pending input in memory instead of in a
* projection would lose queued messages on `resume` — the pending splices live
* in the durable log, so the state has to be rebuilt from it.
*
* So the fold is reproduced here, field for field, from
* `dsh-agent-loop/lib/index.js:26-60` (0.2.1-rc.2, MIT). The projection KEY
* must stay `"inbox"` and `stateVersion` must stay 1: a different key would
* silently read as "no pending input" for sessions written by the stock loop,
* and a different version would make `stateSchema.parse` refuse those rows.
*
* ★ The one deliberate deviation: the host validates with zod
* (`z.array(z.custom())`), which is a dependency this plugin does not carry.
* {@link inboxProjectionSchema} provides the same contract the projection
* registry actually uses — `parse()` that throws on a malformed persisted row —
* without pulling zod in. Nothing else about the fold changed.
*
* @module dsh-memory/lib/loop/inbox
*/
/**
* Validate one persisted inbox state.
*
* The registry calls `stateSchema.parse` on durable checkpoint rows and on the
* value a fold returns, so this must reject anything that is not the two-array
* shape. Message contents stay opaque (`z.custom()` in the host): they are
* already validated by the session layer when they were added to the surface,
* and re-validating them here would make the fold able to reject a log the
* session accepted.
*/
export const inboxProjectionSchema = {
/**
* @param {unknown} value candidate state
* @returns {{'next-turn': Array<object>, 'next-step': Array<object>}} the same object
* @throws {Error} when the shape is not two message arrays
*/
parse(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('inbox projection state must be an object')
}
const nextTurn = value['next-turn']
const nextStep = value['next-step']
if (!Array.isArray(nextTurn) || !Array.isArray(nextStep)) {
throw new Error('inbox projection state must hold "next-turn" and "next-step" message arrays')
}
return value
},
}
/** Standard fold that reconstructs pending input and rejects invalid splice history. */
export const inboxProjectionDefinition = {
key: 'inbox',
stateSchema: inboxProjectionSchema,
init: () => ({
'next-turn': [],
'next-step': [],
}),
apply(state, event) {
if (event.type !== 'agent/inbox/spliced') return state
const splice = event.data
try {
const inbox = state[splice.target]
const removedCount = splice.removedCount ?? 0
if (
!Number.isSafeInteger(splice.start) ||
splice.start < 0 ||
splice.start > inbox.length ||
!Number.isSafeInteger(removedCount) ||
removedCount < 0 ||
splice.start + removedCount > inbox.length
) {
throw new Error('invalid inbox splice')
}
const next = inbox.toSpliced(splice.start, removedCount, ...splice.inserted)
const ids = new Set()
for (const message of splice.target === 'next-turn' ? [...next, ...state['next-step']] : [...state['next-turn'], ...next]) {
if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`)
ids.add(message.id)
}
return splice.target === 'next-turn'
? { 'next-turn': next, 'next-step': state['next-step'] }
: { 'next-turn': state['next-turn'], 'next-step': next }
} catch (error) {
throw new Error(`invalid persisted inbox splice at session seq ${event.seq}`, { cause: error })
}
},
wire: {
viewSchema: inboxProjectionSchema,
view: (state) => state,
},
stateVersion: 1,
}
/**
* Driver-owned durable Inbox implementation.
*
* Every mutation is a durable `agent/inbox/spliced` append; the projection is
* the only state holder, so a resumed session sees exactly the pending input
* the previous process left behind.
*/
export class ReactLoopInbox {
/**
* @param {object} projections `ctx.sessionProjections`
* @param {object} session the owning session
* @param {object} dispatch agent-scoped dispatcher
*/
constructor(projections, session, dispatch) {
this.projections = projections
this.session = session
this.dispatch = dispatch
}
/** Prompts awaiting individual turns. */
get nextTurn() {
return this.current()['next-turn']
}
/** Input awaiting the next step boundary. */
get nextStep() {
return this.current()['next-step']
}
/** Whether either pending-message list contains work. */
get hasPending() {
const state = this.current()
return state['next-turn'].length > 0 || state['next-step'].length > 0
}
/** Durably cancel all pending input, clearing next-step before next-turn. */
clear() {
this.splice('next-step', 0, this.nextStep.length, [])
this.splice('next-turn', 0, this.nextTurn.length, [])
}
/**
* Remove and return the complete batch proposed for one step.
* @param {'next-turn'|'next-step'} target whether this boundary also consumes one queued turn
* @param {number} turn turn that will own the claimed batch
*/
claim(target, turn) {
const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false)
if (target === 'next-turn') claimed.push(...this.mutate('next-turn', 0, 1, [], false))
for (const message of claimed) this.dispatch.emit('agent/inbox/claimed', { message, turn })
return claimed
}
/**
* Append one message to a pending list.
* @param {'next-turn'|'next-step'} target pending list to extend
* @param {object} message message to append
*/
append(target, message) {
this.splice(target, this.current()[target].length, 0, [message])
}
/**
* Prepend one message to a pending list.
* @param {'next-turn'|'next-step'} target pending list to extend
* @param {object} message message to prepend
*/
prepend(target, message) {
this.splice(target, 0, 0, [message])
}
/**
* Replace one pending message in place.
* @param {string} messageId identity of the pending message to replace
* @param {object} newMessage replacement message
* @returns {boolean} whether the message was still pending
*/
replace(messageId, newMessage) {
const location = this.locate(messageId)
if (location === undefined) return false
this.splice(location.target, location.index, 1, [newMessage])
return true
}
/**
* Remove one pending message.
* @param {string} messageId identity of the pending message to remove
* @returns {boolean} whether the message was still pending
*/
remove(messageId) {
const location = this.locate(messageId)
if (location === undefined) return false
this.splice(location.target, location.index, 1, [])
return true
}
/**
* Apply standard splice semantics and durably record the normalized result.
* @param {'next-turn'|'next-step'} target pending list to mutate
* @param {number} start splice position
* @param {number} deleteCount maximum number of messages to remove
* @param {Array<object>} inserted messages to insert at the resolved position
*/
splice(target, start, deleteCount, inserted) {
return this.mutate(target, start, deleteCount, inserted, true)
}
/** Locate one pending identity across both owned lists. */
locate(messageId) {
const state = this.current()
for (const target of ['next-turn', 'next-step']) {
const index = state[target].findIndex((message) => message.id === messageId)
if (index >= 0) return { target, index }
}
}
/** Read the current durable projection state. */
current() {
const state = this.projections.stateOf(this.session, 'inbox')
if (state === undefined) throw new Error(`agent "${this.session.id}" cannot read inbox state: its projection registration is not active`)
return state
}
/** Commit one normalized mutation and publish its live events. */
mutate(target, start, deleteCount, inserted, discardRemoved) {
const state = this.current()
const inbox = state[target]
const truncatedStart = Math.trunc(start)
const offset = Number.isNaN(truncatedStart) ? 0 : truncatedStart
const actualStart = offset < 0 ? Math.max(inbox.length + offset, 0) : Math.min(offset, inbox.length)
const truncatedDeleteCount = Math.trunc(deleteCount)
const actualDeleteCount = Math.min(Math.max(Number.isNaN(truncatedDeleteCount) ? 0 : truncatedDeleteCount, 0), inbox.length - actualStart)
if (actualDeleteCount === 0 && inserted.length === 0) return []
const candidate = inbox.toSpliced(actualStart, actualDeleteCount, ...inserted)
const ids = new Set()
for (const message of target === 'next-turn' ? [...candidate, ...state['next-step']] : [...state['next-turn'], ...candidate]) {
if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`)
ids.add(message.id)
}
const outcome = discardRemoved && actualDeleteCount > 0 ? 'canceled' : undefined
const splice = {
target,
start: actualStart,
...(actualDeleteCount === 0 ? {} : { removedCount: actualDeleteCount }),
inserted,
...(outcome === undefined ? {} : { outcome }),
}
const removed = inbox.slice(actualStart, actualStart + actualDeleteCount)
const event = this.session.append('agent/inbox/spliced', splice)
if (discardRemoved) for (const message of removed) this.dispatch.emit('agent/inbox/discarded', { message })
for (const message of event.data.inserted) this.dispatch.emit('agent/inbox/inserted', { message })
return removed
}
}

145
lib/loop/runtime-context.js Normal file
View File

@ -0,0 +1,145 @@
/**
* The two surface projections the loop owns: the rendered system prompt and
* the dynamic runtime-context snapshot.
*
* ## Why this is a copy and not an import
*
* `dsh-agent-loop` does not export either class (its entry exports only
* `AgentLoop`, the constants and `turnBoundaryProjectionDefinition`,
* dsh-agent-loop/lib/index.js:2023). They are nevertheless required: without
* `SystemPromptProjection` the prompt would never reach surface node 0, and
* without `RuntimeContextProjection` every step would append a duplicate
* snapshot. Both are reproduced from
* `dsh-agent-loop/lib/types/index.js:211-351` (0.2.1-rc.2, MIT) unchanged.
*
* ★ `SOURCE = 'runtime-context'` is load-bearing and must not be renamed: the
* stock loop also owns snapshots under that `source.kind`, so a session that
* switches between the stock loop and this one (a profile patch applied
* mid-life) must still recognise the previous process's snapshot as its own.
* A different kind would leave two snapshots in the surface, both claiming to
* supersede the other.
*
* @module dsh-memory/lib/loop/runtime-context
*/
import { createSystemMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session'
const SOURCE = 'runtime-context'
const CLEARED = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.'
/** @param {object} message derived message */
function isOwned(message) {
return message.source.kind === SOURCE
}
/** @param {object} message derived message */
function textOf(message) {
const [block] = message.content
return message.content.length === 1 && block?.type === 'text' ? block.text : undefined
}
/** Committed events from the newest backward; the restore scans stop at the first match. */
function eventsNewestFirst(session) {
return session.snapshotEvents().toReversed()
}
/**
* Decides how a rendered system prompt reaches the surface without owning the
* commit. The first prompt, even empty, reserves surface node 0.
*/
export class SystemPromptProjection {
/** @param {object} session */
constructor(session) {
this.session = session
}
/** The surviving `system/message` nodes in surface order. */
systemNodes() {
const nodes = []
for (const seq of this.session.surface.nodes) {
const event = this.session.eventAt(seq)
if (event?.type !== 'system/message') continue
const text = event.data.message.content.length === 0 ? '' : textOf(event.data.message)
nodes.push({ seq, text })
}
return nodes
}
/**
* Reconcile effective text and retained nodes with the prepared route and series.
* @param {string} rendered the fully rendered system prompt; `''` when none is active
* @param {{inHistory: boolean, startsSeries: boolean}} input route capability and series facts
*/
project(rendered, input) {
const nodes = this.systemNodes()
const head = nodes[0]
if (head === undefined) return [{ message: createSystemMessage(rendered), intent: { surfaceOp: 'append' } }]
const latest = nodes.findLast((node) => node.text !== '') ?? head
if (!input.inHistory || input.startsSeries || rendered.length === 0) {
const updates = nodes.slice(1).filter((node) => node.text !== '').map((node) => this.replace(node.seq, ''))
if (head.text !== rendered) updates.push(this.replace(head.seq, rendered))
return updates
}
if (latest.text === rendered) return []
return [{ message: createSystemMessage(rendered), intent: { surfaceOp: 'append' } }]
}
/**
* @param {number} seq surface node to replace
* @param {string} text replacement text
*/
replace(seq, text) {
return {
message: createSystemMessage(text),
intent: { surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq] },
}
}
}
/** Tracks the last retained runtime-context snapshot without owning its commit. */
export class RuntimeContextProjection {
/** `undefined` means no snapshot ever existed; `null` means none is retained. */
retained
/**
* Restore projection state once, then follow authoritative session events.
* @param {object} ctx agent-scoped event context
* @param {object} session session receiving projected messages
*/
constructor(ctx, session) {
const surface = new Set(session.surface.nodes)
for (const event of eventsNewestFirst(session)) {
if (event.type !== 'user/message' || !isOwned(event.data)) continue
this.retained ??= null
if (surface.has(event.seq)) {
this.retained = { seq: event.seq, text: textOf(event.data) }
break
}
}
ctx.on('session/event', (subject, event) => {
if (subject !== session) return
if (event.type === 'user/message' && isOwned(event.data)) {
this.retained = { seq: event.seq, text: textOf(event.data) }
} else if (this.retained && isReplacementSurfaceEvent(event) && event.sourceEventSeqs?.includes(this.retained.seq) === true) {
this.retained = null
}
})
}
/**
* Create an uncommitted snapshot only when the retained value differs.
* @param {string} current fully rendered dynamic context
* @param {Array<{name: string, text: string}>} sections named contributions of the snapshot
* @returns {object|undefined} a candidate user message, or undefined when no update is needed
*/
project(current, sections) {
if (this.retained === undefined && current.length === 0) return
const snapshot = current.length === 0 ? CLEARED : current
if (this.retained?.text === snapshot) return
return createUserMessage({
content: [{ type: 'text', text: snapshot }],
source: sections.length === 0 ? { kind: SOURCE } : { kind: SOURCE, form: 'snapshot', sections },
})
}
}

254
lib/loop/tool-calls.js Normal file
View File

@ -0,0 +1,254 @@
/**
* Schedules one assistant step's tool calls. Exclusive calls form barriers;
* parallel calls use a bounded rolling pool and are reclassified before start.
* Dispatch may overlap, while policy, results, and result context remain
* model-ordered. Abort or an internal scheduler failure stops replenishment
* and drains started calls.
*
* Reproduced from `dsh-agent-loop/lib/types/tool-calls.js:474-708`
* (0.2.1-rc.2, MIT); the loop replacement deliberately changes no scheduling
* behaviour, so this file is a port rather than a rewrite.
*
* ★ One substitution: the host reads its parallelism cap from
* `ctx.agentLoop.config.maxParallelToolCalls` (dsh-agent-loop/lib/index.js:553),
* a service this plugin does not provide (its only consumer is the Web settings
* panel, which is out of scope). The caller passes the same value explicitly
* through {@link ToolCallSchedulerOptions.maxParallelToolCalls} instead; the
* cap is still a live getter, so a dynamic override keeps working.
*
* @module dsh-memory/lib/loop/tool-calls
*/
import { createToolResultMessage } from '@deepseek-ai/dsh-llm'
import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_RUNTIME_SCHEDULER } from '@deepseek-ai/dsh-tools'
/**
* Throw on a value the compiler proved unreachable.
*
* Inlined rather than imported from `@deepseek-ai/dsh-util-values`, and that is a
* G2b decision, not an oversight: `dsh-util-values` is a *dependency* of
* `dsh-agent-loop`, not one of its peers (dsh-agent-loop/package.json). Adding it
* here would therefore introduce a runtime dependency this plugin carries itself
* — exactly what G2b exists to catch — and it would break H5 (no new deps) for a
* three-line helper. The four lines below are the upstream body; the message
* prefix stays identical so a log line still names the same call site.
*
* @param {never} value the value that cannot occur
* @param {string} [context] name of the switch that produced it
* @throws {Error} always
*/
function assertNever(value, context) {
const rendered = JSON.stringify(value) ?? String(value)
throw new Error(`unreachable variant${context ? ` in ${context}` : ''}: ${rendered}`)
}
/**
* Schedule one assistant step's tool calls by their live concurrency mode.
*
* Ordinary completion and abort commit started-call results in order. Abort
* drains them, records synthetic results for unstarted calls, and returns with
* the signal still aborted after accepting started-call context through the
* caller-supplied acceptor.
*
* @param {object} ctx loop context that owns the tool registry and carries the initiating Agent
* @param {number} turn current turn number
* @param {number} step current step number
* @param {Array<object>} toolCalls assistant calls in model order
* @param {AbortSignal} signal abort signal shared by the step
* @param {(context: object) => void} acceptContext accepts committed result context for the next step
* @param {{maxParallelToolCalls: () => number}} options the live parallelism cap
* @returns {Promise<{concluded: boolean}>}
*/
export async function executeToolCalls(ctx, turn, step, toolCalls, signal, acceptContext, options) {
const agent = ctx.agents.requireInitiator()
const { session } = agent
const planned = toolCalls.map((block) => ({
block,
exec: { callId: block.id, name: block.name, arguments: parseArguments(block.arguments), agent, signal },
}))
let next = 0
let concluded = false
while (next < planned.length) {
const first = planned[next]
const mode = ctx.tools.executionMode(first.exec).kind
const outcome = await runGroup(
ctx,
turn,
step,
mode === 'parallel' ? planned.slice(next) : [first],
mode,
signal,
acceptContext,
options,
)
next += outcome.consumed
concluded ||= outcome.concluded
if (outcome.aborted) {
for (const call of planned.slice(next)) appendSkippedToolCall(session, turn, step, call.block)
return { concluded }
}
}
return { concluded }
}
/** Parse model arguments, preserving invalid JSON as text and mapping empty input to `{}`. */
function parseArguments(raw) {
try {
return raw ? JSON.parse(raw) : {}
} catch {
return raw
}
}
/**
* Run one exclusive barrier or parallel pool.
*
* @param {object} ctx
* @param {number} turn
* @param {number} step
* @param {Array<{block: object, exec: object}>} group
* @param {'parallel'|'exclusive'} mode
* @param {AbortSignal} signal
* @param {(context: object) => void} acceptContext
* @param {{maxParallelToolCalls: () => number}} options
*/
async function runGroup(ctx, turn, step, group, mode, signal, acceptContext, options) {
const { session } = ctx.agents.requireInitiator()
const maxParallelToolCalls = options.maxParallelToolCalls()
const slots = group.map(() => undefined)
const callSeqs = group.map(() => undefined)
let nextToStart = 0
let committed = 0
let started = 0
let aborted = signal.aborted
let concluded = false
let schedulerFailure
const throwSchedulerFailure = () => {
if (schedulerFailure !== undefined) throw schedulerFailure.error
}
const commitReady = async () => {
while (committed < group.length) {
const slot = slots[committed]
if (slot === undefined) break
const call = group[committed]
const result = slot.needsPost
? await ctx.tools[TOOL_RUNTIME_SCHEDULER].finalize(slot.exec, slot.result)
: ctx.tools[TOOL_RUNTIME_SCHEDULER].finish(slot.exec, slot.result)
appendToolResult(session, turn, step, call.block, result, callSeqs[committed])
for (const context of result.additionalContexts ?? []) acceptContext(context)
concluded ||= result.concludesTurn === true
committed++
}
}
const inFlight = new Map()
const startCall = async (index) => {
const call = group[index]
callSeqs[index] = appendToolCall(session, turn, step, call.block)
started++
const prepared = await ctx.tools[TOOL_RUNTIME_SCHEDULER].prepare(call.exec)
throwSchedulerFailure()
switch (prepared.kind) {
case 'dispatch': {
const promise = ctx.tools[TOOL_RUNTIME_SCHEDULER].dispatch(prepared.exec).then(
(outcome) => {
slots[index] = { exec: prepared.exec, result: outcome.result, needsPost: outcome.kind === 'post-result' }
return index
},
(error) => {
schedulerFailure ??= { error }
return index
},
)
inFlight.set(index, promise)
break
}
case 'post-result':
slots[index] = { exec: prepared.exec, result: prepared.result, needsPost: true }
break
case 'final-result':
slots[index] = { exec: prepared.exec, result: prepared.result, needsPost: false }
break
default:
assertNever(prepared, 'tool-call scheduler prepare result')
}
}
const fillPool = async () => {
while (!aborted && nextToStart < group.length && inFlight.size < maxParallelToolCalls) {
const nextCall = group[nextToStart]
if (nextToStart > 0 && mode === 'parallel' && ctx.tools.executionMode(nextCall.exec).kind !== 'parallel') break
await startCall(nextToStart)
nextToStart++
throwSchedulerFailure()
await commitReady()
throwSchedulerFailure()
if (signal.aborted) aborted = true
}
}
try {
await fillPool()
while (inFlight.size > 0) {
const settledIndex = await Promise.race(inFlight.values())
inFlight.delete(settledIndex)
throwSchedulerFailure()
await commitReady()
throwSchedulerFailure()
if (signal.aborted) aborted = true
await fillPool()
}
} catch (error) {
schedulerFailure ??= { error }
await Promise.allSettled(inFlight.values())
throw schedulerFailure.error
}
if (aborted) {
for (const call of group.slice(started)) appendSkippedToolCall(session, turn, step, call.block)
return { consumed: group.length, aborted: true, concluded }
}
if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls')
return { consumed: started, aborted: false, concluded }
}
/** Append the durable call/result pair for a model call skipped after cancellation. */
function appendSkippedToolCall(session, turn, step, block) {
const callSeq = appendToolCall(session, turn, step, block)
appendToolResult(
session,
turn,
step,
block,
{
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: {
message: 'tool call aborted before dispatch',
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
},
},
callSeq,
)
}
/** Append a started call and return the event seq that its result must cite. */
function appendToolCall(session, turn, step, block) {
return session.append('tool/call', { turn, step, callId: block.id, name: block.name, arguments: block.arguments }).seq
}
/** Append a model-ordered result linked to its call event. */
function appendToolResult(session, turn, step, block, result, callSeq) {
const message = createToolResultMessage({
callId: block.id,
content: result.content,
isError: result.isError,
})
session.append(
'tool/result',
{
turn,
step,
message,
...(result.error?.info ? { error: result.error.info } : {}),
...(result.meta !== undefined ? { meta: result.meta } : {}),
},
{ surfaceOp: 'append', sourceEventSeqs: [callSeq] },
)
}

1195
lib/recall.js Normal file

File diff suppressed because it is too large Load Diff

1276
lib/store.js Normal file

File diff suppressed because it is too large Load Diff

46
package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "dsh-memory",
"version": "0.1.0",
"description": "HologramAgent-style block memory (stablemem) for dsh: sqlite + FTS5 recall, no embedding.",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js",
"./loop": "./lib/loop.js"
},
"files": [
"lib/*.js",
"lib/embed/*.js",
"lib/loop.js",
"lib/loop/*.js",
"lib/types/*.d.ts"
],
"engines": {
"node": ">=22.5.0"
},
"scripts": {
"test": "node --test test/store.test.js test/packaging.test.js test/plugin.test.js test/loader.test.js test/phase-b-a.test.js test/phase-b-g.test.js test/phase-b-g-static.test.js test/phase-b-h.test.js test/phase-b-p1-vector-wiring.test.js test/phase-b-c4-centering.test.js test/phase-b-c5-reasons.test.js test/phase-b-v6-sides.test.js test/recall-accounting.test.js test/memory-log.test.js test/loop.test.js test/loop-ports.test.js test/loop-system-e2e.test.js",
"check": "node scripts/checkpoints.js"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.5",
"@deepseek-ai/dsh-agent-loop": "0.2.1-rc.2",
"@deepseek-ai/dsh-llm": "0.2.1-rc.2",
"@deepseek-ai/dsh-session": "0.2.1-rc.2",
"@deepseek-ai/dsh-system-prompt": "0.2.1-rc.2",
"@deepseek-ai/dsh-tools": "0.2.1-rc.2"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-system-prompt": {
"optional": true
},
"@deepseek-ai/dsh-tools": {
"optional": true
},
"@deepseek-ai/dsh-llm": {
"optional": true
}
},
"license": "MIT",
"private": true
}

495
scripts/checkpoints.js Normal file
View File

@ -0,0 +1,495 @@
#!/usr/bin/env node
/**
* Checkpoint verification for dsh-memory phase A (docs/feasibility.md §7.1).
*
* Runs against a **real cordis app** with the real `@deepseek-ai` services, not
* mocks: V3/V3b/V4/V5 are only meaningful if the actual `SystemPrompt`,
* `ToolRuntime` and event dispatch are in the loop.
*
* Usage: node scripts/checkpoints.js
* Exit: 0 = every checkpoint holds, 1 = at least one failed.
*
* @module dsh-memory/scripts/checkpoints
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync, existsSync } from 'node:fs'
import { DatabaseSync } from 'node:sqlite'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
import { ToolRuntime } from '@deepseek-ai/dsh-tools'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { BlockStore } from '../lib/store.js'
import { recallBlocks } from '../lib/recall.js'
import { apply as applyMemory, name as pluginName, inject as pluginInject } from '../lib/index.js'
const results = []
let failed = 0
/**
* Run one checkpoint and record its outcome.
* @param {string} id
* @param {string} title
* @param {() => Promise<unknown> | unknown} body
* @returns {Promise<void>}
*/
async function checkpoint(id, title, body) {
try {
const detail = await body()
results.push({ id, title, ok: true, detail })
console.log(` ✔ ${id} ${title}${detail ? `\n ${detail}` : ''}`)
} catch (error) {
failed += 1
results.push({ id, title, ok: false, detail: error.message })
console.log(` ✘ ${id} ${title}\n ${error.message}`)
}
}
/** A fresh temp dir per run, removed at the end. */
const workspace = mkdtempSync(join(tmpdir(), 'dsh-memory-cp-'))
/**
* Build a real cordis app with the services this plugin injects.
*
* `ToolRuntime` needs `systemPrompt` first (dsh-tools/lib/index.js:2761), and our
* plugin needs both — so construction order mirrors a real composition.
* @returns {{ ctx: Context, dispose: () => Promise<void> }}
*/
function makeApp() {
const ctx = new Context()
new SystemPrompt(ctx, { includeHarnessIdentity: false })
new ToolRuntime(ctx, {})
return { ctx, dispose: async () => {} }
}
console.log('\ndsh-memory · phase A checkpoints\n')
// ── V1: node:sqlite usable, WAL, concurrent writes ────────────────────────
await checkpoint('V1', 'node:sqlite opens, WAL applies, 100 concurrent writes round-trip', () => {
const path = join(workspace, 'v1.db')
const store = new BlockStore({ path }).open()
assert.equal(store.journalMode(), 'wal', 'journal_mode should be wal')
for (let i = 0; i < 100; i += 1) {
store.putBlock({ text: `并发写入测试块 编号 ${i} 端口 ${10000 + i}`, source: 'v1' })
}
assert.equal(store.countBlocks(), 100, 'expected 100 blocks')
const readBack = store.getBlock(
(() => {
// Look one up by content to prove the row is intact, not just counted.
const rows = store.allBlocks(100)
return rows.find((row) => row.text_content.includes('端口 10050')).id
})(),
)
assert.ok(readBack, 'the block written mid-batch must be readable')
assert.ok(readBack.text_content.includes('10050'), 'text_content intact')
store.close()
assert.ok(existsSync(path), 'db file exists on disk')
return `journal=wal, rows=100, mid-batch row=${readBack.id}, file on disk`
})
// ── V2: FTS5 + symbol-pin recall quality on a 50-block corpus ─────────────
//
// The corpus is split by HologramAgent's own capability tiers
// (docs/zh/recall-capability-tiers.md), because they have different mechanisms
// and must be scored separately rather than averaged into one misleading number:
//
// 形态 1 带精确串(端口/IP/路径/版本) symbol path MUST pass
// 形态 2 领域词 + 上下文 FTS + CJK window MUST pass
// 形态 3 纯泛化描述("XX 是什么") needs vectors KNOWN GAP
// 形态 4 库里没有 needs abstention KNOWN GAP
//
// 形态 3/4 are asserted to be gaps, not hidden: this slice has no embedding
// provider (docs/feasibility.md §5.1), so they are phase B/C work. Encoding the
// gap as an assertion means the day someone adds vectors, this test tells them.
await checkpoint('V2', 'recall quality per capability tier (tiers 1–2 must pass; 3–4 are pinned gaps)', () => {
const store = new BlockStore({ path: join(workspace, 'v2.db') }).open()
/** @type {Array<{q: string, text: string, want: string, tier: number}>} */
const corpus = []
// 形态 1 — exact-string queries. 30 notes.
for (let i = 0; i < 10; i += 1) {
corpus.push({ q: `billing${i} 端口是多少`, text: `billing${i}服务 端口=${8499 + i}`, want: String(8499 + i), tier: 1 })
}
for (let i = 0; i < 10; i += 1) {
corpus.push({ q: `值班室分机号 43${20 + i}`, text: `值班室分机号 43${20 + i},值班人 老周`, want: String(4320 + i), tier: 1 })
}
for (let i = 0; i < 10; i += 1) {
corpus.push({ q: `脚本 /opt/deploy/run${i}.sh 在哪`, text: `部署脚本 路径=/opt/deploy/run${i}.sh`, want: `/opt/deploy/run${i}.sh`, tier: 1 })
}
// 形态 2 — domain word + context, including the CJK-question form that needs
// the sliding-window fallback (query run ≠ note run). 20 notes.
//
// ★ Each note must have a DISTINCT subject. An earlier draft of this corpus
// used ten notes differing only by a number hidden in the query's parentheses,
// and scored 55% — but that was the corpus being unfair, not recall being bad:
// ten notes reading "值班室分机号 43xx,值班人 老周" are genuinely
// indistinguishable unless the query names the number, so top1 is arbitrary.
// Distinct subjects is the shape a real corpus has (one note per thing).
const services = ['admin', 'billing', 'oauth', 'gateway', 'search', 'report', 'notify', 'audit', 'cache', 'proxy']
for (const [i, svc] of services.entries()) {
corpus.push({
// Question form: names the service, not the port.
q: `${svc} 服务的端口是多少`,
text: `${svc}服务 端口=${8800 + i}`,
want: String(8800 + i),
tier: 2,
})
}
for (const [i, batch] of ['第112批', '第115批', '第132批', '第140批', '第151批'].entries()) {
corpus.push({
q: `发布 ${batch} 的版本号是什么`,
text: `发布记录 ${batch} 的版本=v2.3${i}.0`,
want: `v2.3${i}.0`,
tier: 2,
})
}
for (const [i, dept] of ['财务', '运维', '客服', '仓储', '采购'].entries()) {
corpus.push({
q: `${dept}部门的负责人是谁`,
text: `${dept}部门 负责人=员工${100 + i}`,
want: `员工${100 + i}`,
tier: 2,
})
}
for (const entry of corpus) store.putBlock({ text: entry.text, source: 'v2' })
/** @param {number} tier */
const scoreTier = (tier) => {
const rows = corpus.filter((entry) => entry.tier === tier)
const misses = []
let hit = 0
for (const entry of rows) {
const got = recallBlocks(store, entry.q, { topK: 1 })[0]?.text ?? ''
if (got.includes(entry.want)) hit += 1
else misses.push({ q: entry.q, want: entry.want, got: got.slice(0, 40) })
}
return { hit, total: rows.length, accuracy: hit / rows.length, misses }
}
const tier1 = scoreTier(1)
const tier2 = scoreTier(2)
// Known gaps, asserted as such (they must NOT silently start "passing" by
// accident, which would mean the corpus lost its discriminating power).
const gapGeneral = recallBlocks(store, '公网地址是什么', { topK: 1 }).length
const gapAbsent = recallBlocks(store, 'kafka broker 的地址', { topK: 1 })
store.close()
assert.ok(tier1.accuracy >= 0.95, `tier1 ${(tier1.accuracy * 100).toFixed(0)}% < 95%; ${JSON.stringify(tier1.misses.slice(0, 3))}`)
assert.ok(tier2.accuracy >= 0.9, `tier2 ${(tier2.accuracy * 100).toFixed(0)}% < 90%; ${JSON.stringify(tier2.misses.slice(0, 3))}`)
// 形态 3/4 是已知缺口:这里不断言"答对",只记录实测行为,防止有人误以为已支持。
return (
`tier1(exact) ${tier1.hit}/${tier1.total} = ${(tier1.accuracy * 100).toFixed(0)}% · ` +
`tier2(domain) ${tier2.hit}/${tier2.total} = ${(tier2.accuracy * 100).toFixed(0)}% · ` +
`known gaps: 泛化查询→${gapGeneral} hit, 库里没有→${gapAbsent.length} hit (需向量,phase B)`
)
})
// ── V3: systemPrompt.context gets agent.session, NOT the step messages ────
await checkpoint('V3', 'systemPrompt.context: sees agent, cannot see this step\u2019s messages', async () => {
const app = makeApp()
let seenKeys = []
app.ctx.systemPrompt.context({
name: 'probe:v3',
order: 210,
text: (context) => {
seenKeys = Object.keys(context).sort()
return 'probe'
},
})
const fakeSession = { marker: 'session-present' }
const assembly = await app.ctx.systemPrompt.assemble({
agent: { session: fakeSession, id: 'v3-agent' },
})
const assembled = app.ctx.systemPrompt.getContextOrder ? assembly.contexts : assembly.contexts
assert.deepEqual(seenKeys, ['agent', 'scope', 'signal'].filter((key) => seenKeys.includes(key)),
`unexpected AssembleContext keys: ${seenKeys.join(',')}`)
assert.ok(seenKeys.includes('agent'), 'AssembleContext must expose agent')
assert.ok(
!seenKeys.includes('messages') && !seenKeys.includes('input') && !seenKeys.includes('text'),
`AssembleContext must NOT expose step messages; got ${seenKeys.join(',')}`,
)
assert.ok(assembled.some((entry) => entry.name === 'probe:v3'), 'context was assembled')
return `AssembleContext keys = [${seenKeys.join(', ')}] — no messages/input field`
})
// ── V3b: agent/pre-step injection reaches the model-visible batch ─────────
await checkpoint('V3b', 'agent/pre-step: injected recall lands in the accepted message batch', async () => {
const app = makeApp()
const mem = applyMemory(app.ctx, { path: join(workspace, 'v3b.db'), topK: 3 })
await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' })
const input = createUserMessage({
content: [{ type: 'text', text: '值班室分机号是多少' }],
source: { kind: 'user' },
})
const decision = await app.ctx.waterfall(
'agent/pre-step',
{ agent: { id: 'a', session: {} }, messages: [input], turn: 1, step: 1, signal: new AbortController().signal },
() => Promise.resolve({ kind: 'enter', messages: [input] }),
)
assert.equal(decision.kind, 'enter', 'step must be admitted')
assert.ok(decision.messages.length > decision.messages.length - 1, 'sanity')
assert.equal(decision.messages.length, 2, `expected input + injected recall, got ${decision.messages.length}`)
const injected = decision.messages[1]
assert.equal(injected.source.kind, pluginName, 'injected message carries our source kind')
assert.equal(injected.source.form, 'snapshot', 'injected message declares form=snapshot')
const text = injected.content.map((block) => block.text ?? '').join('')
assert.ok(text.includes('4324'), `injected text must carry the recalled value; got ${text.slice(0, 120)}`)
assert.ok(text.includes('PAST notes'), 'injected text must state these are historical')
return `batch ${decision.messages.length} msgs; injected kind=${injected.source.kind}, carries "4324"`
})
// ── V3b mutation: recall returning '' must make V3b's assertion fail ──────
await checkpoint('V3b†', 'MUTATION: silencing recall must break V3b (the test can turn red)', async () => {
const app = makeApp()
const mem = applyMemory(app.ctx, { path: join(workspace, 'v3b-mut.db'), topK: 3 })
await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' })
// Mutate: no recall, ever.
mem.contextForStep = () => ''
const input = createUserMessage({
content: [{ type: 'text', text: '值班室分机号是多少' }],
source: { kind: 'user' },
})
const decision = await app.ctx.waterfall(
'agent/pre-step',
{ agent: { id: 'a', session: {} }, messages: [input], turn: 1, step: 1, signal: new AbortController().signal },
() => Promise.resolve({ kind: 'enter', messages: [input] }),
)
assert.equal(decision.messages.length, 1, 'mutation must leave the batch un-augmented')
return 'with recall disabled the batch stays at 1 message ⇒ the V3b assertion would fail'
})
// ── V3c: REALISTIC batch (question + runtime-context + skill-catalog) ─────
//
// V3b builds a 2-message batch, which is exactly the blind spot that let a
// 100%-dead auto-recall ship. A real dsh step batch appends user-ROLE injected
// context after the question, so "last role=user" picks up the skill catalog.
await checkpoint('V3c', 'realistic 3-part batch: recall uses the question, not the injected context', async () => {
const app = makeApp()
const mem = applyMemory(app.ctx, { path: join(workspace, 'v3c.db'), topK: 3 })
await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' })
const batch = [
{ role: 'user', source: { kind: 'user' },
content: [{ type: 'text', text: '值班室分机号是多少' }] },
{ role: 'user', source: { kind: 'runtime-context', form: 'snapshot' },
content: [{ type: 'text', text: '[Working directory]\n/home/agent/project' }] },
{ role: 'user', source: { kind: 'skill-catalog', form: 'catalog' },
content: [{ type: 'text', text: '[Skills] git-release-discipline, android-cli, shared-browser' }] },
]
const decision = await app.ctx.waterfall(
'agent/pre-step',
{ agent: { id: 'a', session: {} }, messages: batch, turn: 1, step: 1,
signal: new AbortController().signal },
() => Promise.resolve({ kind: 'enter', messages: batch }),
)
assert.equal(decision.messages.length, 4, '3 batch + 1 injected')
const text = decision.messages[3].content.map((b) => b.text ?? '').join('')
assert.ok(text.includes('4324'), `recall must hit; injected=${JSON.stringify(text.slice(0, 80))}`)
// The mutation, measured in the same run: the old rule must score 0.
const oldRuleQuery = batch[batch.length - 1].content[0].text
// `recall` is async since phase B group A — a bare Promise has no `.length`.
const mutated = (await mem.recall(oldRuleQuery)).length
const fixed = (await mem.recall('值班室分机号是多少')).length
assert.equal(mutated, 0, 'old rule (skill catalog as query) must score 0 hits')
assert.ok(fixed >= 1, 'fixed rule must score >= 1')
return `batch 4 msgs, hit "4324" · old-rule hits=${mutated} vs fixed hits=${fixed}`
})
// ── V4: tools.register output.schema / render contract ────────────────────
await checkpoint('V4', 'ctx.tools.register: stablemem_commit validates args and renders output', async () => {
const app = makeApp()
const mem = applyMemory(app.ctx, { path: join(workspace, 'v4.db') })
const schemas = app.ctx.tools.schemas()
const tool = schemas.find((schema) => schema.name === 'stablemem_commit')
assert.ok(tool, `stablemem_commit not registered; got ${schemas.map((s) => s.name).join(',')}`)
assert.ok(tool.description.includes('durable'), 'description reaches the model')
assert.ok(tool.parameters?.properties?.text, 'parameters expose `text`')
const result = await app.ctx.tools.execute({
callId: 'v4-call-1',
name: 'stablemem_commit',
arguments: { text: 'V4 校验块:etcd 集群端口 2379', source: 'v4' },
signal: new AbortController().signal,
})
assert.equal(result.isError, false, `tool failed: ${JSON.stringify(result)}`)
assert.ok(typeof result.value === 'string' && result.value.includes('Committed'), `unexpected value ${result.value}`)
assert.ok(Array.isArray(result.content) && result.content[0]?.type === 'text', 'render produced a text block')
assert.equal(mem.store.countBlocks(), 1, 'block persisted')
return `${schemas.length} tool(s) visible; execute → "${result.value}"`
})
// ── V5: dispose releases the handle (idempotent, file unlocked) ───────────
await checkpoint('V5', 'unload disposes the sqlite handle; a second close is a no-op', async () => {
const path = join(workspace, 'v5.db')
const app = makeApp()
const mem = applyMemory(app.ctx, { path })
await mem.commit({ text: 'V5 句柄释放块', source: 'v5' })
assert.ok(mem.store.isOpen, 'store open while plugin loaded')
// Disposing the plugin fiber must run our ctx.effect teardown.
await app.ctx.fiber.dispose()
assert.equal(mem.store.isOpen, false, 'store must be closed after fiber disposal')
// Idempotent: a second close (test teardown, double unload) must not throw.
mem.store.close()
// The strongest proof the lock is gone: reopen the same file for writing.
const reopened = new BlockStore({ path }).open()
assert.equal(reopened.countBlocks(), 1, 'data survived; file was not locked')
reopened.close()
return 'fiber dispose → handle closed; reopened same file successfully'
})
// ── A5 mutation: LIKE-style containment must destroy discrimination ───────
await checkpoint('A5†', 'MUTATION: swapping FTS5 for containment breaks discrimination', () => {
const store = new BlockStore({ path: join(workspace, 'a5.db') }).open()
store.putBlock({ text: '值班室分机号 4324', source: 'm' })
store.putBlock({ text: '值班室分机号 4379 已停用', source: 'm' })
store.putBlock({ text: 'billing服务 端口=8499', source: 'm' })
const precise = recallBlocks(store, '4324', { topK: 3 })
assert.equal(precise.length, 1, `exact query should hit exactly one block, got ${precise.length}`)
// The mutation: primary path becomes "contains any CJK run" — everything matches.
const mutated = store
.allBlocks(100)
.filter((row) => row.text_content.includes('值班室'))
assert.ok(
mutated.length >= 2,
'sanity: containment over a shared prefix must match multiple blocks',
)
store.close()
return `FTS+pin → 1 hit for "4324"; containment → ${mutated.length} hits (discrimination lost)`
})
// ── MIG3: an existing phase-A database survives the phase-B upgrade ───────
//
// Lives in `check` as well as `test` on purpose: "can the user's existing
// memory.db still be used" is the single most important thing to see at a
// glance, and more people read this output than read the test suite. The
// failure it guards against is silent at load — the store opens, stamps a
// version, accepts writes to blocks, and only throws on the first centroid
// write — so a casual smoke test would miss it.
await checkpoint('MIG3', 'phase-A database opens, migrates, and stays fully writable', () => {
const path = join(workspace, 'phase-a.db')
// Build the phase-A shape by hand, including its OLD centroid table.
const legacy = new DatabaseSync(path)
legacy.exec(`CREATE TABLE memory_blocks (
id TEXT PRIMARY KEY, modality TEXT NOT NULL, text_content TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '', scene TEXT NOT NULL DEFAULT '',
semantic_type TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`)
legacy.exec(`CREATE TABLE graph_centroid (
fingerprint TEXT PRIMARY KEY, dim INTEGER NOT NULL, count INTEGER NOT NULL,
sum_json TEXT NOT NULL, updated_at INTEGER NOT NULL)`)
legacy.exec(`CREATE VIRTUAL TABLE memory_blocks_fts
USING fts5(text_content, block_id UNINDEXED, tokenize='unicode61 remove_diacritics 2')`)
legacy.exec(`INSERT INTO memory_blocks (id, modality, text_content, source, created_at, updated_at)
VALUES ('blk_legacy', 'text', '值班室分机号 4324', 'phase-a', 1, 1)`)
legacy.exec(`INSERT INTO memory_blocks_fts (text_content, block_id)
VALUES ('值班室分机号 4324', 'blk_legacy')`)
legacy.close()
const store = new BlockStore({ path }).open()
const version = store.userVersion()
const cols = store.columns('memory_blocks')
assert.ok(cols.includes('vector') && cols.includes('fingerprint'), `columns: ${cols.join(',')}`)
assert.equal(version, 1, `user_version after migration: ${version}`)
// The legacy block must survive AND still be findable.
assert.ok(store.getBlock('blk_legacy'), 'the phase-A block must survive')
assert.ok(store.searchFts('"4324"', 5).length >= 1, 'FTS must still find the legacy block')
// Both write paths must work — the block path AND the centroid path.
store.putBlock({ text: '迁移之后的新块 8499' })
const centroidCols = store.columns('graph_centroid')
store.saveCentroid({ fingerprint: 'fp-mig', dim: 3, vectorCount: 1, mean: [0, 0, 0], isAnomalous: true })
assert.ok(store.loadCentroid('fp-mig'), 'centroid must round-trip after migration')
store.close()
return `uv=${version}, blocks=${cols.length} cols, centroid=${centroidCols.length} cols, legacy block readable + FTS-matching`
})
// ── F7: store-level fingerprint inventory, D4-derived and D7-cheap ────────
await checkpoint('F7', 'store stats groups active and stale fingerprints with DB-derived counts', () => {
const path = join(workspace, 'f7.db')
const store = new BlockStore({ path }).open()
try {
store.putBlock({ text: 'F7 active', vector: [1, 0, 0], fingerprint: 'fp-active' })
store.putBlock({ text: 'F7 stale one', vector: [1, 0], fingerprint: 'fp-stale' })
store.putBlock({ text: 'F7 stale two', vector: [0, 1], fingerprint: 'fp-stale' })
store.putBlock({ text: 'F7 no vector' })
store.saveCentroid({ fingerprint: 'fp-active', dim: 3, vectorCount: 1, mean: [0, 0, 0], isAnomalous: false })
store.saveCentroid({ fingerprint: 'fp-stale', dim: 2, vectorCount: 2, mean: [0, 0], isAnomalous: false })
const report = store.vectorStats('fp-active')
assert.deepEqual(report.blocks, { total: 4, with_vector: 3, no_vector: 1 })
assert.deepEqual(report.spaces, {
active: { fingerprint: 'fp-active', dim: 3 },
by_fingerprint: [
{ fingerprint: 'fp-active', dim: 3, blocks: 1, status: 'active' },
{ fingerprint: 'fp-stale', dim: 2, blocks: 2, status: 'stale' },
],
no_fingerprint: 0,
})
return 'active=1 block/3D, stale=2 blocks/2D, no_vector=1; dimensions came from centroids'
} finally {
store.close()
}
})
// ── F8: explicit width errors, provider width errors degrade ─────────────
await checkpoint('F8', 'dimension validation separates caller errors from provider degradation', async () => {
const explicitApp = makeApp()
const explicit = applyMemory(explicitApp.ctx, {
path: join(workspace, 'f8-explicit.db'),
embedder: { info: () => ({ fingerprint: 'fp-f8', dimension: 3 }), embedText: async () => [1, 0, 0] },
})
try {
await assert.rejects(
() => explicit.commit({ text: 'F8 caller error', vector: [1, 2], fingerprint: 'fp-f8' }),
/does not match the active space/,
)
assert.equal(explicit.store.countBlocks(), 0, 'caller width error must not write a block')
await explicit.commit({ text: 'F8 matching caller vector', vector: [1, 2, 3], fingerprint: 'fp-f8' })
assert.equal(explicit.store.countBlocks(), 1, 'matching explicit vector is accepted')
} finally {
await explicitApp.ctx.fiber.dispose()
}
const providerApp = makeApp()
const provider = applyMemory(providerApp.ctx, {
path: join(workspace, 'f8-provider.db'),
embedder: { info: () => ({ fingerprint: 'fp-f8-provider', dimension: 3 }), embedText: async () => [1, 2] },
})
try {
const result = await provider.commit({ text: 'F8 provider error' })
assert.equal(result.embedded, false, 'provider width error must degrade without throwing')
assert.equal(provider.store.countBlocks(), 1, 'degraded provider write still persists the block')
assert.equal(provider.store.allBlocks(1)[0].vector, '', 'the bad provider vector is not stored')
} finally {
await providerApp.ctx.fiber.dispose()
}
return 'caller wrong width: rejected/no row; matching width: accepted; provider wrong width: degraded/row retained/vector empty'
})
// ── cleanup ───────────────────────────────────────────────────────────────
rmSync(workspace, { recursive: true, force: true })
const passed = results.filter((entry) => entry.ok).length
console.log(`\n${passed}/${results.length} checkpoints passed\n`)
console.log(`plugin: name=${pluginName} inject=[${pluginInject.join(', ')}]`)
console.log(`workspace: ${workspace} (removed)\n`)
if (results.length === 0) {
console.error('no checkpoints were registered — did the helper get renamed or stubbed?\n')
process.exitCode = 1
}
if (failed > 0) {
console.error(`${failed} checkpoint(s) FAILED\n`)
process.exitCode = 1
}

55
scripts/link-dsh.sh Executable file
View File

@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Link the globally-installed dsh packages into node_modules.
#
# The plugin declares no dependency of its own, so nothing here is fetched from a
# registry. The dsh packages it *imports* at runtime are **peers**: they must come
# from the running harness, and npm will not install them for us. During
# development they live in
# /usr/lib/node_modules/@deepseek-ai (a global install), so this script wires them
# up by symlink instead of vendoring a copy that could drift from the harness.
#
# Usage: ./scripts/link-dsh.sh [global-modules-root]
set -euo pipefail
GLOBAL_ROOT="${1:-/usr/lib/node_modules}"
PEERS=(
cordis
dsh-agent
dsh-agent-loop
cosmokit
dsh-llm
dsh-session
dsh-scope
dsh-system-prompt
dsh-tools
dsh-util-values
cordis-plugin-loader
)
cd "$(dirname "$0")/.."
if [[ ! -d "$GLOBAL_ROOT/@deepseek-ai" ]]; then
echo "error: $GLOBAL_ROOT/@deepseek-ai not found — pass the root that holds the dsh install" >&2
exit 1
fi
mkdir -p node_modules/@deepseek-ai
for peer in "${PEERS[@]}"; do
src="$GLOBAL_ROOT/@deepseek-ai/$peer"
if [[ ! -d "$src" ]]; then
echo " skip $peer (not installed)"
continue
fi
ln -sfn "$src" "node_modules/@deepseek-ai/$peer"
echo " link $peer"
done
# js-yaml is a transitive dep of cordis-plugin-include; needed only by the
# loader integration test, and only when it is not resolvable already.
if [[ -d "$GLOBAL_ROOT/js-yaml" && ! -e node_modules/js-yaml ]]; then
ln -sfn "$GLOBAL_ROOT/js-yaml" node_modules/js-yaml
echo " link js-yaml"
fi
echo
echo "done. try: npm test && npm run check"

714
test/fixtures/golden-phase-a.json vendored Normal file
View File

@ -0,0 +1,714 @@
{
"corpus": [
{
"text": "值班室分机号 4324,值班人 老周",
"source": "qq"
},
{
"text": "值班室分机号 4379 已停用",
"source": "qq"
},
{
"text": "billing服务 端口=8499",
"source": "ops"
},
{
"text": "billing2服务 端口=8501",
"source": "ops"
},
{
"text": "admin服务 端口=8861",
"source": "ops"
},
{
"text": "部署脚本 路径=/opt/deploy/run3.sh",
"source": "ops"
},
{
"text": "发布批次 第112批 的版本=v2.30.0",
"source": "ci"
},
{
"text": "发布记录 第115批 的版本=v2.31.0",
"source": "ci"
},
{
"text": "本机 13010 端口对应网关",
"source": "net"
},
{
"text": "数据库连接串 postgres://db:5432/app",
"source": "ops"
},
{
"text": "监控面板 http://10.0.0.7:3000",
"source": "ops"
},
{
"text": "财务部门 负责人=员工100",
"source": "hr"
}
],
"cases": [
{
"name": "exact-numeric",
"query": "4324 是多少",
"topK": 1,
"hits": [
{
"id": "blk_25496b163a54",
"score": 1.003322623116964,
"exact": true,
"why": "exact:4324",
"text": "值班室分机号 4324,值班人 老周"
}
]
},
{
"name": "exact-numeric",
"query": "4324 是多少",
"topK": 3,
"hits": [
{
"id": "blk_25496b163a54",
"score": 1.003322623116964,
"exact": true,
"why": "exact:4324",
"text": "值班室分机号 4324,值班人 老周"
}
]
},
{
"name": "exact-numeric",
"query": "4324 是多少",
"topK": 5,
"hits": [
{
"id": "blk_25496b163a54",
"score": 1.003322623116964,
"exact": true,
"why": "exact:4324",
"text": "值班室分机号 4324,值班人 老周"
}
]
},
{
"name": "exact-numeric-absent",
"query": "9999 是多少",
"topK": 1,
"hits": []
},
{
"name": "exact-numeric-absent",
"query": "9999 是多少",
"topK": 3,
"hits": []
},
{
"name": "exact-numeric-absent",
"query": "9999 是多少",
"topK": 5,
"hits": []
},
{
"name": "cjk-substring",
"query": "分机号",
"topK": 1,
"hits": [
{
"id": "blk_25496b163a54",
"score": 0.75,
"exact": false,
"why": "lexical",
"text": "值班室分机号 4324,值班人 老周"
}
]
},
{
"name": "cjk-substring",
"query": "分机号",
"topK": 3,
"hits": [
{
"id": "blk_25496b163a54",
"score": 0.75,
"exact": false,
"why": "lexical",
"text": "值班室分机号 4324,值班人 老周"
},
{
"id": "blk_db476841debd",
"score": 0.75,
"exact": false,
"why": "lexical",
"text": "值班室分机号 4379 已停用"
}
]
},
{
"name": "cjk-substring",
"query": "分机号",
"topK": 5,
"hits": [
{
"id": "blk_25496b163a54",
"score": 0.75,
"exact": false,
"why": "lexical",
"text": "值班室分机号 4324,值班人 老周"
},
{
"id": "blk_db476841debd",
"score": 0.75,
"exact": false,
"why": "lexical",
"text": "值班室分机号 4379 已停用"
}
]
},
{
"name": "cjk-question",
"query": "值班室分机号是多少",
"topK": 1,
"hits": [
{
"id": "blk_25496b163a54",
"score": 0.3333333333333333,
"exact": false,
"why": "lexical",
"text": "值班室分机号 4324,值班人 老周"
}
]
},
{
"name": "cjk-question",
"query": "值班室分机号是多少",
"topK": 3,
"hits": [
{
"id": "blk_25496b163a54",
"score": 0.3333333333333333,
"exact": false,
"why": "lexical",
"text": "值班室分机号 4324,值班人 老周"
},
{
"id": "blk_db476841debd",
"score": 0.3333333333333333,
"exact": false,
"why": "lexical",
"text": "值班室分机号 4379 已停用"
}
]
},
{
"name": "cjk-question",
"query": "值班室分机号是多少",
"topK": 5,
"hits": [
{
"id": "blk_25496b163a54",
"score": 0.3333333333333333,
"exact": false,
"why": "lexical",
"text": "值班室分机号 4324,值班人 老周"
},
{
"id": "blk_db476841debd",
"score": 0.3333333333333333,
"exact": false,
"why": "lexical",
"text": "值班室分机号 4379 已停用"
}
]
},
{
"name": "latin-word",
"query": "billing 服务的端口是多少",
"topK": 1,
"hits": [
{
"id": "blk_248c9ea6912a",
"score": 0.501,
"exact": false,
"why": "word:billing",
"text": "billing服务 端口=8499"
}
]
},
{
"name": "latin-word",
"query": "billing 服务的端口是多少",
"topK": 3,
"hits": [
{
"id": "blk_248c9ea6912a",
"score": 0.501,
"exact": false,
"why": "word:billing",
"text": "billing服务 端口=8499"
},
{
"id": "blk_1a22661e3bca",
"score": 0.5,
"exact": false,
"why": "word:billing",
"text": "billing2服务 端口=8501"
},
{
"id": "blk_0e348cbeacfb",
"score": 0.1875,
"exact": false,
"why": "lexical",
"text": "本机 13010 端口对应网关"
}
]
},
{
"name": "latin-word",
"query": "billing 服务的端口是多少",
"topK": 5,
"hits": [
{
"id": "blk_248c9ea6912a",
"score": 0.501,
"exact": false,
"why": "word:billing",
"text": "billing服务 端口=8499"
},
{
"id": "blk_1a22661e3bca",
"score": 0.5,
"exact": false,
"why": "word:billing",
"text": "billing2服务 端口=8501"
},
{
"id": "blk_0e348cbeacfb",
"score": 0.1875,
"exact": false,
"why": "lexical",
"text": "本机 13010 端口对应网关"
},
{
"id": "blk_4ef71225f4f6",
"score": 0.1875,
"exact": false,
"why": "lexical",
"text": "admin服务 端口=8861"
}
]
},
{
"name": "latin-prefix-collision",
"query": "billing 端口",
"topK": 1,
"hits": [
{
"id": "blk_248c9ea6912a",
"score": 0.751,
"exact": false,
"why": "word:billing",
"text": "billing服务 端口=8499"
}
]
},
{
"name": "latin-prefix-collision",
"query": "billing 端口",
"topK": 3,
"hits": [
{
"id": "blk_248c9ea6912a",
"score": 0.751,
"exact": false,
"why": "word:billing",
"text": "billing服务 端口=8499"
},
{
"id": "blk_1a22661e3bca",
"score": 0.75,
"exact": false,
"why": "word:billing",
"text": "billing2服务 端口=8501"
},
{
"id": "blk_4ef71225f4f6",
"score": 0.75,
"exact": false,
"why": "lexical",
"text": "admin服务 端口=8861"
}
]
},
{
"name": "latin-prefix-collision",
"query": "billing 端口",
"topK": 5,
"hits": [
{
"id": "blk_248c9ea6912a",
"score": 0.751,
"exact": false,
"why": "word:billing",
"text": "billing服务 端口=8499"
},
{
"id": "blk_1a22661e3bca",
"score": 0.75,
"exact": false,
"why": "word:billing",
"text": "billing2服务 端口=8501"
},
{
"id": "blk_4ef71225f4f6",
"score": 0.75,
"exact": false,
"why": "lexical",
"text": "admin服务 端口=8861"
}
]
},
{
"name": "version",
"query": "版本 v2.30.0 是哪一批",
"topK": 1,
"hits": [
{
"id": "blk_2bf27acf69db",
"score": 1.0037232350815941,
"exact": true,
"why": "exact:v2.30.0",
"text": "发布批次 第112批 的版本=v2.30.0"
}
]
},
{
"name": "version",
"query": "版本 v2.30.0 是哪一批",
"topK": 3,
"hits": [
{
"id": "blk_2bf27acf69db",
"score": 1.0037232350815941,
"exact": true,
"why": "exact:v2.30.0",
"text": "发布批次 第112批 的版本=v2.30.0"
}
]
},
{
"name": "version",
"query": "版本 v2.30.0 是哪一批",
"topK": 5,
"hits": [
{
"id": "blk_2bf27acf69db",
"score": 1.0037232350815941,
"exact": true,
"why": "exact:v2.30.0",
"text": "发布批次 第112批 的版本=v2.30.0"
}
]
},
{
"name": "path",
"query": "/opt/deploy/run3.sh 在哪",
"topK": 1,
"hits": [
{
"id": "blk_38a71c737eef",
"score": 1.001892383734559,
"exact": true,
"why": "exact:run3",
"text": "部署脚本 路径=/opt/deploy/run3.sh"
}
]
},
{
"name": "path",
"query": "/opt/deploy/run3.sh 在哪",
"topK": 3,
"hits": [
{
"id": "blk_38a71c737eef",
"score": 1.001892383734559,
"exact": true,
"why": "exact:run3",
"text": "部署脚本 路径=/opt/deploy/run3.sh"
}
]
},
{
"name": "path",
"query": "/opt/deploy/run3.sh 在哪",
"topK": 5,
"hits": [
{
"id": "blk_38a71c737eef",
"score": 1.001892383734559,
"exact": true,
"why": "exact:run3",
"text": "部署脚本 路径=/opt/deploy/run3.sh"
}
]
},
{
"name": "port",
"query": "13010 端口",
"topK": 1,
"hits": [
{
"id": "blk_0e348cbeacfb",
"score": 1.0031028290329878,
"exact": true,
"why": "exact:13010",
"text": "本机 13010 端口对应网关"
}
]
},
{
"name": "port",
"query": "13010 端口",
"topK": 3,
"hits": [
{
"id": "blk_0e348cbeacfb",
"score": 1.0031028290329878,
"exact": true,
"why": "exact:13010",
"text": "本机 13010 端口对应网关"
},
{
"id": "blk_1a22661e3bca",
"score": 0.75,
"exact": false,
"why": "lexical",
"text": "billing2服务 端口=8501"
},
{
"id": "blk_248c9ea6912a",
"score": 0.75,
"exact": false,
"why": "lexical",
"text": "billing服务 端口=8499"
}
]
},
{
"name": "port",
"query": "13010 端口",
"topK": 5,
"hits": [
{
"id": "blk_0e348cbeacfb",
"score": 1.0031028290329878,
"exact": true,
"why": "exact:13010",
"text": "本机 13010 端口对应网关"
},
{
"id": "blk_1a22661e3bca",
"score": 0.75,
"exact": false,
"why": "lexical",
"text": "billing2服务 端口=8501"
},
{
"id": "blk_248c9ea6912a",
"score": 0.75,
"exact": false,
"why": "lexical",
"text": "billing服务 端口=8499"
},
{
"id": "blk_4ef71225f4f6",
"score": 0.75,
"exact": false,
"why": "lexical",
"text": "admin服务 端口=8861"
}
]
},
{
"name": "url",
"query": "10.0.0.7 是什么",
"topK": 1,
"hits": [
{
"id": "blk_04bf9028222f",
"score": 1.002450474896629,
"exact": true,
"why": "exact:10.0.0.7",
"text": "监控面板 http://10.0.0.7:3000"
}
]
},
{
"name": "url",
"query": "10.0.0.7 是什么",
"topK": 3,
"hits": [
{
"id": "blk_04bf9028222f",
"score": 1.002450474896629,
"exact": true,
"why": "exact:10.0.0.7",
"text": "监控面板 http://10.0.0.7:3000"
}
]
},
{
"name": "url",
"query": "10.0.0.7 是什么",
"topK": 5,
"hits": [
{
"id": "blk_04bf9028222f",
"score": 1.002450474896629,
"exact": true,
"why": "exact:10.0.0.7",
"text": "监控面板 http://10.0.0.7:3000"
}
]
},
{
"name": "mixed",
"query": "postgres 连接串 5432",
"topK": 1,
"hits": [
{
"id": "blk_6707cd66f5e2",
"score": 1.002269299108547,
"exact": true,
"why": "exact:5432",
"text": "数据库连接串 postgres://db:5432/app"
}
]
},
{
"name": "mixed",
"query": "postgres 连接串 5432",
"topK": 3,
"hits": [
{
"id": "blk_6707cd66f5e2",
"score": 1.002269299108547,
"exact": true,
"why": "exact:5432",
"text": "数据库连接串 postgres://db:5432/app"
}
]
},
{
"name": "mixed",
"query": "postgres 连接串 5432",
"topK": 5,
"hits": [
{
"id": "blk_6707cd66f5e2",
"score": 1.002269299108547,
"exact": true,
"why": "exact:5432",
"text": "数据库连接串 postgres://db:5432/app"
}
]
},
{
"name": "cjk-dept",
"query": "财务部门的负责人是谁",
"topK": 1,
"hits": [
{
"id": "blk_3d254170bb70",
"score": 0.30000000000000004,
"exact": false,
"why": "lexical",
"text": "财务部门 负责人=员工100"
}
]
},
{
"name": "cjk-dept",
"query": "财务部门的负责人是谁",
"topK": 3,
"hits": [
{
"id": "blk_3d254170bb70",
"score": 0.30000000000000004,
"exact": false,
"why": "lexical",
"text": "财务部门 负责人=员工100"
}
]
},
{
"name": "cjk-dept",
"query": "财务部门的负责人是谁",
"topK": 5,
"hits": [
{
"id": "blk_3d254170bb70",
"score": 0.30000000000000004,
"exact": false,
"why": "lexical",
"text": "财务部门 负责人=员工100"
}
]
},
{
"name": "unrelated",
"query": "zzz 完全无关 zzz",
"topK": 1,
"hits": []
},
{
"name": "unrelated",
"query": "zzz 完全无关 zzz",
"topK": 3,
"hits": []
},
{
"name": "unrelated",
"query": "zzz 完全无关 zzz",
"topK": 5,
"hits": []
},
{
"name": "empty",
"query": "",
"topK": 1,
"hits": []
},
{
"name": "empty",
"query": "",
"topK": 3,
"hits": []
},
{
"name": "empty",
"query": "",
"topK": 5,
"hits": []
},
{
"name": "cjk-generic-gap",
"query": "公网地址是什么",
"topK": 1,
"hits": []
},
{
"name": "cjk-generic-gap",
"query": "公网地址是什么",
"topK": 3,
"hits": []
},
{
"name": "cjk-generic-gap",
"query": "公网地址是什么",
"topK": 5,
"hits": []
}
]
}

144
test/loader.test.js Normal file
View File

@ -0,0 +1,144 @@
/**
* Real-loader integration test: mount the plugin the way a dsh profile does.
*
* This is the one test that proves the *packaging* is right — `name`/`inject`/
* `apply` exports, the `defineTool` contract, and `systemPrompt.context` all
* survive an actual `cordis-plugin-loader` mount rather than a hand-built app.
*
* If `cordis-plugin-loader` cannot be resolved (bare install, no dsh), the test
* skips rather than fails: the plugin itself does not depend on the loader.
*
* @module dsh-memory/test/loader
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { Context } from '@deepseek-ai/cordis'
import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
import { ToolRuntime } from '@deepseek-ai/dsh-tools'
import * as plugin from '../lib/index.js'
/** @returns {Promise<unknown>} the loader module, or null when unavailable */
async function tryLoadLoader() {
try {
return await import('@deepseek-ai/cordis-plugin-loader')
} catch {
return null
}
}
test('plugin exports the shape the cordis loader requires', () => {
// A loader entry is only mountable when these three exist.
assert.equal(typeof plugin.apply, 'function', 'apply(ctx, config) is required')
assert.equal(typeof plugin.name, 'string', 'name is required for diagnostics')
assert.ok(Array.isArray(plugin.inject), 'inject must be an array')
assert.deepEqual(plugin.inject, ['systemPrompt', 'tools'])
// The default export must be the same plugin (loaders unwrap `default`).
assert.equal(plugin.default.name, plugin.name)
assert.equal(plugin.default.apply, plugin.apply)
})
test('mounting twice on the same app fails loudly (service name is taken)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-memory-dup-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
try {
plugin.apply(ctx, { path: join(dir, 'a.db') })
// 'stablemem' is provided by the first instance; cordis refuses a second
// implementation in the same realm — which is exactly why a preset that
// wants its own implementation must isolate the service name
// (docs/shadow-context.md §5.2).
assert.throws(
() => plugin.apply(ctx, { path: join(dir, 'b.db') }),
/stablemem/,
'duplicate service registration must be rejected',
)
} finally {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
}
})
test('an isolated realm can host its own stablemem (the preset route)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-memory-iso-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
try {
const outer = plugin.apply(ctx, { path: join(dir, 'outer.db') })
// A preset subtree isolates the service name and mounts a second instance.
// Prompt-context names and tool names are NOT partitioned by isolate()
// (dsh-system-prompt/lib/index.js:204, dsh-tools duplicate check), so a
// second instance needs a distinct tool name. The prompt-context name is
// derived from the db path automatically.
const realm = ctx.isolate('stablemem')
const inner = plugin.apply(realm, {
path: join(dir, 'inner.db'),
toolName: 'stablemem_commit_alt',
statsToolName: 'stablemem_stats_alt',
})
// Both awaits are required: commit() embeds (async) before writing, and
// relying on "no embedder means it happens synchronously" is exactly the
// fragile assumption this guards against. Promise.all keeps them concurrent,
// which is what this test is about (two isolated realms writing in parallel).
await Promise.all([
outer.commit({ text: 'outer-only 端口 1111' }),
inner.commit({ text: 'inner-only 端口 2222' }),
])
assert.equal(outer.store.countBlocks(), 1, 'outer store isolated')
assert.equal(inner.store.countBlocks(), 1, 'inner store isolated')
assert.notEqual(outer.store.path, inner.store.path, 'separate databases')
// The isolated service is reachable through the realm context, and the two
// contexts resolve DIFFERENT implementations — that is the whole point.
const realmService = realm.stablemem
// cordis hands out a PROXY, so identity differs from the instance we built;
// compare behaviour, not references. (This proxy is also why StableMem keeps
// ordinary fields instead of #private ones.)
assert.equal(realmService.store.countBlocks(), 1, 'realm sees only its own block')
assert.equal(realmService.store.path, inner.store.path, 'realm resolves the inner instance')
assert.equal(ctx.stablemem.store.path, outer.store.path, 'root resolves the outer instance')
assert.equal(ctx.stablemem.store.countBlocks(), 1, 'outer store untouched by the realm')
} finally {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
}
})
test('a real cordis loader can mount the plugin by specifier', async (t) => {
const loaderModule = await tryLoadLoader()
if (loaderModule === null) {
t.skip('cordis-plugin-loader not installed in this environment')
return
}
const dir = mkdtempSync(join(tmpdir(), 'dsh-memory-loader-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
try {
const pluginSpecifier = new URL('../lib/index.js', import.meta.url).href
const fiber = ctx.plugin(
{
name: pluginSpecifier,
inject: plugin.inject,
apply: plugin.apply,
},
{ path: join(dir, 'loaded.db') },
)
await fiber.await()
assert.ok(ctx.stablemem, 'the loaded plugin registered ctx.stablemem')
assert.ok(
ctx.tools.schemas().some((schema) => schema.name === 'stablemem_commit'),
'the loaded plugin registered its tool',
)
} finally {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
}
})

154
test/loop-ports.test.js Normal file
View File

@ -0,0 +1,154 @@
/**
* Contract for the four ported loop modules under `lib/loop/`.
*
* These files are a faithful port of the stock loop's internals into this
* package, so nothing here tests their behaviour — that is `loop.test.js` and
* the phase-B suites. What this file pins is the **port interface**, because
* every one of these failures is silent at runtime:
*
* L1 all four modules import on their own (no ordering, no index.js).
* L2 the export names a replacement loop will import are present.
* L3 `inboxProjectionSchema.parse` actually rejects a malformed persisted
* row. This is the ONE thing the port re-implemented instead of copying
* (it replaces a zod schema with a hand-written shape check), so it is
* exactly the thing that can be a no-op and still look fine.
* L4 the inbox projection KEY and `stateVersion` are unchanged. A different
* key reads as "no pending input" for sessions written by the stock loop;
* a different version makes `stateSchema.parse` refuse those rows. Both
* are quiet data loss on `resume`, not a crash.
*
* @module dsh-memory/test/loop-ports
*/
import assert from 'node:assert/strict'
import test from 'node:test'
// ── L1: each module stands alone ─────────────────────────────────────────
//
// Imported dynamically rather than at the top of the file so the assertion is
// per-module: a single top-level `import` would make one broken port fail the
// whole file with "load failed" and hide which of the four broke.
const MODULES = {
'assistant-stream': '../lib/loop/assistant-stream.js',
inbox: '../lib/loop/inbox.js',
'runtime-context': '../lib/loop/runtime-context.js',
'tool-calls': '../lib/loop/tool-calls.js',
}
test('L1: all four loop ports import successfully on their own', async () => {
for (const [name, specifier] of Object.entries(MODULES)) {
const mod = await import(specifier)
assert.ok(mod !== null && typeof mod === 'object', `${name} must import to a module namespace`)
}
})
// ── L2: the export surface a replacement loop depends on ─────────────────
//
// Named explicitly, not counted: the point is that `executeToolCalls`,
// `ReactLoopInbox`, … are still exported under those names, because a rename
// here only shows up as `undefined is not a function` at step() time.
test('L2: loop ports export the names the replacement loop imports', async () => {
const toolCalls = await import(MODULES['tool-calls'])
assert.equal(typeof toolCalls.executeToolCalls, 'function', 'tool-calls must export executeToolCalls')
const assistantStream = await import(MODULES['assistant-stream'])
assert.equal(
typeof assistantStream.AssistantStreamAttempt,
'function',
'assistant-stream must export AssistantStreamAttempt',
)
const runtimeContext = await import(MODULES['runtime-context'])
assert.equal(
typeof runtimeContext.SystemPromptProjection,
'function',
'runtime-context must export SystemPromptProjection',
)
assert.equal(
typeof runtimeContext.RuntimeContextProjection,
'function',
'runtime-context must export RuntimeContextProjection',
)
const inbox = await import(MODULES.inbox)
assert.equal(typeof inbox.ReactLoopInbox, 'function', 'inbox must export ReactLoopInbox')
assert.ok(
inbox.inboxProjectionDefinition !== null && typeof inbox.inboxProjectionDefinition === 'object',
'inbox must export inboxProjectionDefinition',
)
assert.ok(
inbox.inboxProjectionSchema !== null && typeof inbox.inboxProjectionSchema === 'object',
'inbox must export inboxProjectionSchema',
)
})
// ── L3: the schema bites ──────────────────────────────────────────────────
//
// The projection registry calls `stateSchema.parse` on durable checkpoint rows,
// so "it has a parse function" proves nothing. These assertions run the parse
// against the two shapes that matter: a real state must come back, and a
// malformed row must throw — not return undefined, not coerce.
test('L3: inboxProjectionSchema.parse accepts a well-formed two-array state', async () => {
const { inboxProjectionSchema } = await import(MODULES.inbox)
const state = {
'next-turn': [{ id: 'm1', role: 'user' }],
'next-step': [{ id: 'm2', role: 'user' }],
}
assert.equal(inboxProjectionSchema.parse(state), state, 'parse must return the same object it validated')
assert.deepEqual(
inboxProjectionSchema.parse({ 'next-turn': [], 'next-step': [] }),
{ 'next-turn': [], 'next-step': [] },
'the init() shape (both empty) must be accepted',
)
})
test('L3: inboxProjectionSchema.parse throws on a malformed persisted row', async () => {
const { inboxProjectionSchema } = await import(MODULES.inbox)
const malformed = [
['null', null],
['a bare array', []],
['a string', 'inbox'],
['an object missing "next-step"', { 'next-turn': [] }],
['a non-array "next-turn"', { 'next-turn': {}, 'next-step': [] }],
['a non-array "next-step"', { 'next-turn': [], 'next-step': 'pending' }],
['the stock loop key casing', { nextTurn: [], nextStep: [] }],
]
for (const [label, value] of malformed) {
assert.throws(
() => inboxProjectionSchema.parse(value),
`parse must reject ${label} — a returning-but-not-throwing parse would accept a corrupt checkpoint`,
)
}
})
// ── L4: the durable identity of the inbox projection ─────────────────────
//
// These two literals are a wire format shared with sessions written by the
// stock `dsh-agent-loop`. Changing either is silent: the stock loop's pending
// splices stop folding in, and the session resumes with an empty inbox.
test('L4: inbox projection keeps key "inbox" and stateVersion 1', async () => {
const { inboxProjectionDefinition, inboxProjectionSchema } = await import(MODULES.inbox)
assert.equal(
inboxProjectionDefinition.key,
'inbox',
'the projection key is a wire format shared with stock-loop sessions — changing it loses their pending input',
)
assert.equal(
inboxProjectionDefinition.stateVersion,
1,
'stateVersion 1 is what existing checkpoint rows carry — a bump makes stateSchema.parse refuse them',
)
assert.equal(
inboxProjectionDefinition.stateSchema,
inboxProjectionSchema,
'the definition must validate with the exported schema, not a private copy',
)
assert.deepEqual(
inboxProjectionDefinition.init(),
{ 'next-turn': [], 'next-step': [] },
'init() must produce the shape stateSchema.parse accepts',
)
})

View File

@ -0,0 +1,194 @@
/**
* End-to-end proof: a recall block produced by the REAL injection shape reaches
* the SYSTEM prompt and does NOT reach any user/message content.
*
* Why this file exists next to `loop.test.js`
* -------------------------------------------
* `loop.test.js` asserts the pure decision rewrite (`promoteSnapshotSections`).
* That is necessary but not sufficient: the bug it fixes was that the rewrite
* never fired on the real injection shape, and a unit test can only catch that
* if it also encodes the real shape. This file closes the loop the other way —
* it mounts the actual `HologramAgentLoop` on real dsh services (`SessionStore`,
* `AgentRegistry`, `LlmRuntime`, `SystemPrompt`, `ToolRuntime`,
* `SessionProjectionRegistry`), drives one real turn (pre-step → request build →
* LLM call), and asserts against **the request object the adapter actually
* received**, not an intermediate decision.
*
* Two directions, both required:
* E1 positive — the recall text appears in the system prompt the adapter got.
* E2 negative — the recall text appears in NO message body the adapter got
* (every message, every block; not just the last one).
*
* Only E1 fails on the broken commit; E2 is already true there because the old
* code left recall in history. E2 is what this fix is *for*: it must stay true
* after the move, otherwise the promotion would duplicate recall rather than
* relocate it.
*
* No network: the "adapter" is a local `LlmAdapter` subclass that records the
* request and yields one text chunk.
*
* @module dsh-memory/test/loop-system-e2e
*/
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { pathToFileURL } from 'node:url'
import test from 'node:test'
import { Context } from '@deepseek-ai/cordis'
import { AgentRegistry } from '@deepseek-ai/dsh-agent'
import { LlmAdapter, LlmRuntime, createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionStore } from '@deepseek-ai/dsh-session'
import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
import { ToolRuntime } from '@deepseek-ai/dsh-tools'
import { HologramAgentLoop } from '../lib/loop.js'
/**
* Load `SessionProjectionRegistry` the way the harness does.
*
* It is a peer of `dsh-agent-loop`, not of this package, so a bare specifier
* only resolves when the repo's `node_modules` happens to link it. Resolving
* from `dsh-agent-loop`'s own location is exactly what a real dsh process does;
* if neither route works there is no harness to run against, and the test skips
* loudly instead of asserting on a half-mounted app.
* @returns {Promise<Function|null>}
*/
async function loadProjectionRegistry() {
try {
return (await import('@deepseek-ai/dsh-session-projection')).SessionProjectionRegistry
} catch {
try {
const peerRequire = createRequire(import.meta.resolve('@deepseek-ai/dsh-agent-loop'))
return (await import(pathToFileURL(peerRequire.resolve('@deepseek-ai/dsh-session-projection')).href))
.SessionProjectionRegistry
} catch {
return null
}
}
}
/** Every text the model would read out of a message body. */
function bodyText(message) {
if (!Array.isArray(message?.content)) return ''
return message.content.map((block) => (typeof block?.text === 'string' ? block.text : '')).join('')
}
/** The system prompt text as the adapter received it. */
function systemText(request) {
return request.messages
.filter((message) => message.role === 'system')
.map((message) => bodyText(message))
.join('\n')
}
/** Every non-system message body, flattened, for the negative direction. */
function historyBlocks(request) {
return request.messages
.filter((message) => message.role !== 'system')
.map((message, index) => ({ index, role: message.role, kind: message.source?.kind, text: bodyText(message) }))
}
test('E1/E2: recall reaches the system prompt and no message body', async (t) => {
const SessionProjectionRegistry = await loadProjectionRegistry()
if (SessionProjectionRegistry === null) {
t.skip('@deepseek-ai/dsh-session-projection not resolvable from this install (no harness to mount)')
return
}
const RECALL = 'RECALLED-NOTE-4471: user prefers evidence over summaries.'
/** Records the request it is called with; yields one stop-finish text chunk. */
const captured = []
class CaptureAdapter extends LlmAdapter {
async *stream(request) {
captured.push(request)
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text: 'acknowledged' }
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'acknowledged' } }
yield { type: 'finish', reason: 'stop' }
}
}
const ctx = new Context()
try {
// Cordis defers service publication by a microtask; each `new` below needs
// the previous one visible, so yield between mounts.
new SessionStore(ctx)
await new Promise((resolve) => setTimeout(resolve, 0))
new AgentRegistry(ctx)
new SystemPrompt(ctx, {})
new ToolRuntime(ctx)
new LlmRuntime(ctx)
new SessionProjectionRegistry(ctx)
await new Promise((resolve) => setTimeout(resolve, 0))
// The loop under test. Mount before any agent exists: `setFactory` runs in
// the constructor and the class refuses a second registration.
new HologramAgentLoop(ctx, { maxParallelToolCalls: 4, agents: [] })
await new Promise((resolve) => setTimeout(resolve, 0))
ctx.llm.registerAdapter(['capture'], new CaptureAdapter())
// The REAL injection shape, reproduced byte for byte from `lib/index.js:932`:
// one text block whose text IS the section text, `form: 'snapshot'`.
ctx.on(
'agent/pre-step',
async (payload, next) => {
const decision = await next()
if (decision.kind === 'reject' || payload.signal.aborted) return decision
return {
...decision,
messages: [
...decision.messages,
createUserMessage({
content: [{ type: 'text', text: RECALL }],
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: RECALL }] },
}),
],
}
},
{ prepend: true },
)
const handle = await ctx.agents.create({
sessionId: 'loop-system-e2e',
agentOptions: { provider: 'capture', model: 'capture-1' },
})
const agent = handle.agent
agent.followup(
createUserMessage({ content: [{ type: 'text', text: 'what did we decide?' }], source: { kind: 'user' } }),
)
await agent.whenIdle()
assert.equal(captured.length, 1, `the adapter must be called exactly once; got ${captured.length} call(s)`)
const request = captured[0]
// ── E1 · positive: the recall is in the system prompt ────────────────
const system = systemText(request)
assert.ok(
system.includes(RECALL),
`E1: recall must appear in the system prompt the adapter received. system prompt was ${JSON.stringify(system)}`,
)
// ── E2 · negative: the recall is in NO message body ──────────────────
const history = historyBlocks(request)
const leaking = history.filter((entry) => entry.text.includes(RECALL))
assert.deepEqual(
leaking,
[],
`E2: recall must not appear in ANY non-system message; leaking messages: ${JSON.stringify(leaking)}`,
)
// E2b · the loop must not duplicate it either: exactly one system message
// may carry it, so a "promote but keep" bug cannot hide behind E1.
const carriers = request.messages.filter((message) => bodyText(message).includes(RECALL))
assert.equal(
carriers.length,
1,
`E2b: exactly one message may carry the recall (a move, not a copy); got ${carriers.length}: ${JSON.stringify(
carriers.map((message) => message.role),
)}`,
)
assert.equal(carriers[0].role, 'system', `E2b: the sole carrier must be the system message; got ${carriers[0].role}`)
} finally {
await ctx.fiber.dispose()
}
})

457
test/loop.test.js Normal file
View File

@ -0,0 +1,457 @@
/**
* Behavioural contract for the loop that promotes snapshot sections.
*
* The loop only earns its keep if a promoted recall block reaches the SYSTEM
* prompt and does NOT reach history. These tests pin exactly that, plus the
* cases where it must keep its hands off.
*
* P1 a pure snapshot carrier message moves into `assembly.sections`.
* P2 the returned decision is a NEW object; the input is not mutated.
* P3 the promoted section carries `interpolate: false` — free text with
* `{{...}}` must not reach the strict interpolator.
* P4a a carrier whose content text equals its sections IS promoted (the real
* path: `lib/index.js:932` always attaches one text block); P4b the same
* message with differing content is NOT; P4c proves the split is decided
* by text equality, not by the presence of content.
* P5 a non-snapshot message is NOT promoted.
* P6 a name collision suffixes instead of overwriting or dropping.
* P7 `reject` decisions pass through untouched.
* P8 a decision with no `assembly` passes through untouched.
* P9 everything that is not promoted survives in `messages`, in order.
* P10 the class does not re-register the factory (no constructor / inject).
*
* @module dsh-memory/test/loop
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { HologramAgentLoop, promoteSnapshotSections } from '../lib/loop.js'
/** Build a user message shaped like a snapshot-style injector emits. */
function snapshotMessage(text, kind = 'memory') {
return {
role: 'user',
content: [],
source: { kind, form: 'snapshot', sections: [{ name: kind, text }] },
}
}
/** Build a plain user message with real content. */
function plainMessage(text) {
return { role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' } }
}
/** A minimal decision carrying an assembly with one existing section. */
function decisionWith(messages, sections = [{ name: 'base', text: 'base prompt' }]) {
return { kind: 'enter', messages, assembly: { sections, variables: {}, tools: [] } }
}
test('P1: a pure snapshot carrier is promoted into assembly.sections', () => {
const decision = decisionWith([plainMessage('what did I decide?'), snapshotMessage('note alpha')])
const out = promoteSnapshotSections(decision)
assert.equal(out.messages.length, 1)
assert.deepEqual(out.messages[0].content, [{ type: 'text', text: 'what did I decide?' }])
assert.equal(out.assembly.sections.length, 2)
const promoted = out.assembly.sections[1]
assert.equal(promoted.text, 'note alpha')
assert.ok(promoted.name.startsWith('memory'))
})
test('P2: the input decision is not mutated', () => {
const original = decisionWith([snapshotMessage('note alpha')])
const before = structuredClone(original)
promoteSnapshotSections(original)
assert.deepEqual(original, before)
})
test('P3: the promoted section opts out of interpolation', () => {
const out = promoteSnapshotSections(decisionWith([snapshotMessage('literal {{not-a-var}} here')]))
assert.equal(out.assembly.sections[1].interpolate, false)
})
test('P4a: a carrier whose content equals its sections IS promoted', () => {
// The old P4 asserted the opposite for every message carrying content, on the
// assumption that "content blocks present" means "the plugin wants the user to
// see this". That assumption is FALSE for the only producer that matters here:
// `lib/index.js:932` always injects exactly ONE text block whose text is
// `sections[0].text` verbatim — content and sections are the same string. So
// the old rule (`content non-empty ⇒ reject`) made `promoteSnapshotSections` a
// permanent no-op on the real recall path and left recall in user/message
// history, which is the bug this pair of tests exists to pin.
const aligned = {
role: 'user',
content: [{ type: 'text', text: 'recalled note' }],
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: 'recalled note' }] },
}
const out = promoteSnapshotSections(decisionWith([aligned]))
assert.equal(out.messages.length, 0, `content == sections must be promoted; got ${out.messages.length} message(s)`)
assert.equal(out.assembly.sections.length, 2, `expected base + promoted; got ${out.assembly.sections.length}`)
assert.equal(
out.assembly.sections[1].text,
'recalled note',
`promoted text must be the content text unchanged; got ${JSON.stringify(out.assembly.sections[1].text)}`,
)
})
test('P4b: a carrier whose content differs from its sections is NOT promoted', () => {
// The old P4 used 'visible' vs 'hidden' — content != sections — which is the
// side that must keep its hands off. That is exactly why it was green on the
// broken commit: it asserted the correct behaviour for the wrong reason and
// never covered the case that actually occurs (`content == sections`). It is
// kept here, correctly named, as one half of the pair.
const drifted = {
role: 'user',
content: [{ type: 'text', text: 'visible' }],
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: 'hidden' }] },
}
const out = promoteSnapshotSections(decisionWith([drifted]))
assert.equal(out.messages.length, 1, `content != sections must stay in history; got ${out.messages.length}`)
assert.equal(out.assembly.sections.length, 1, `no section may be added; got ${out.assembly.sections.length}`)
})
test('P4c: P4a and P4b are decided by TEXT, not by "has content at all"', () => {
// One decision holding both carriers: if the implementation matched on the
// mere presence of content, both would land on the same side and the
// messages/sections counts below could not both hold.
const aligned = {
role: 'user',
content: [{ type: 'text', text: 'recalled note' }],
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: 'recalled note' }] },
}
const drifted = {
role: 'user',
content: [{ type: 'text', text: 'visible' }],
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: 'hidden' }] },
}
const out = promoteSnapshotSections(decisionWith([aligned, drifted]))
assert.deepEqual(out.messages, [drifted], 'only the mismatching carrier may survive')
assert.equal(out.assembly.sections.length, 2, `only the matching carrier may be promoted; got ${out.assembly.sections.length}`)
})
test('P5: a non-snapshot message is not promoted', () => {
const runtime = {
role: 'user',
content: [],
source: { kind: 'runtime-context', form: 'inline', sections: [{ name: 'x', text: 'y' }] },
}
const out = promoteSnapshotSections(decisionWith([runtime]))
assert.equal(out.messages.length, 1)
assert.equal(out.assembly.sections.length, 1)
})
test('P6: a name collision is suffixed, never dropped', () => {
const out = promoteSnapshotSections(
decisionWith([snapshotMessage('note', 'base')], [{ name: 'base', text: 'base prompt' }]),
)
assert.equal(out.assembly.sections.length, 2)
const names = out.assembly.sections.map((section) => section.name)
assert.equal(new Set(names).size, names.length, 'section names must stay unique')
assert.equal(out.assembly.sections[1].text, 'note')
})
test('P7: a reject decision passes through untouched', () => {
const reject = { kind: 'reject', reason: 'nope' }
assert.equal(promoteSnapshotSections(reject), reject)
})
test('P8: a decision without an assembly passes through untouched', () => {
const bare = { kind: 'enter', messages: [snapshotMessage('note')] }
assert.equal(promoteSnapshotSections(bare), bare)
})
test('P9: non-promoted messages keep their order', () => {
const out = promoteSnapshotSections(
decisionWith([plainMessage('first'), snapshotMessage('note'), plainMessage('second')]),
)
assert.deepEqual(
out.messages.map((message) => message.content[0].text),
['first', 'second'],
)
})
test('P10: the subclass does not re-declare the factory or its inject list', () => {
// A second `setFactory` registration throws at startup, and re-declaring
// `inject` would pull a duplicate AgentLoop into the container. Both are
// avoided by not defining a constructor or static inject at all.
assert.equal(Object.prototype.hasOwnProperty.call(HologramAgentLoop, 'inject'), false)
const source = HologramAgentLoop.prototype.constructor.toString()
assert.equal(source.includes('constructor'), false, 'subclass must not define a constructor')
assert.equal(typeof HologramAgentLoop.prototype.prepare, 'function')
})
// ─────────────────────────────────────────────────────────────────────────
// S1–S7 · the two canonical spellings, and the ones that must not be touched
// ─────────────────────────────────────────────────────────────────────────
//
// Why this block exists: every test above builds its snapshot carrier with
// `content: []`. That is a shape NO real producer emits — `lib/index.js:932`
// always attaches exactly one `{ type: 'text' }` block whose text is
// `sections[0].text` verbatim. So the whole suite was green over a code path
// that never runs in production, and `promoteSnapshotSections` was a no-op on
// every real recall. The rule under test is three-way:
//
// content == sections (either joining convention) → PROMOTE, moving the text
// content != sections → leave it in history
//
// The invariant is "move, don't rewrite": the promoted section text must be
// *identical* to the text the model would otherwise have read out of the
// message content, so these tests can tell a relocation from a silent rewrite.
//
// ★ Every negative case below carries a POSITIVE CONTROL in the same decision:
// a carrier of the same shape that *must* be promoted. Without the control a
// negative assertion is satisfied by the old "content non-empty ⇒ reject" rule
// alone, so it would be green on the broken commit and prove nothing. With it,
// the assertion fails unless the implementation is comparing the two texts.
/** A user message whose content and sections are the SAME single text. */
function carrier(text, kind = 'memory') {
return {
role: 'user',
content: [{ type: 'text', text }],
source: { kind, form: 'snapshot', sections: [{ name: kind, text }] },
}
}
/**
* A carrier whose N sections are rendered into ONE content block.
*
* The name states the shape: ONE text block. It models the two joining
* conventions ('\n\n' and '') on that single block, which is what
* `lib/index.js:932` actually emits.
*
* ★ This helper was previously called `multiCarrier` while producing a single
* block, and that name is exactly what let the V2 bug (`if (blocks.length > 1)
* return null`) pass untouched: S2a/S2b read as multi-block coverage while
* exercising none of it. A helper whose name overstates its shape is worse than
* a missing test, because it looks like coverage. Real multi-block carriers come
* from `trueMultiCarrier` below and are exercised by S2c/S2d.
* @param {string[]} texts one entry per section
* @param {'glued'|'spaced'} convention how the sections were concatenated
*/
function singleBlockCarrier(texts, convention = 'spaced', kind = 'memory') {
const joined = convention === 'glued' ? texts.join('') : texts.join('\n\n')
return {
role: 'user',
content: [{ type: 'text', text: joined }],
source: { kind, form: 'snapshot', sections: texts.map((text, i) => ({ name: `${kind}-${i}`, text })) },
}
}
/**
* 产出:N 个 text block,每个 block 一个 section 的文本(rendered = texts.join(''))。
* 这不是本插件产出的形状,是"其它 producer 逐块发文本"的形状;只用来证明多块路径有牙。
* 注意:真多块时 rendered 恒为 glued 拼法,没有 spaced 变体 —— spaced 只由单块承载。
* @param {string[]} texts one entry per block AND per section
* @param {string} kind source kind
*/
function trueMultiCarrier(texts, kind = 'memory') {
return {
role: 'user',
content: texts.map((text) => ({ type: 'text', text })),
source: { kind, form: 'snapshot', sections: texts.map((text, i) => ({ name: `${kind}-${i}`, text })) },
}
}
/** Readable "expected vs actual" rendering for the assertion messages. */
const show = (value) => JSON.stringify(value)
test('S1: single block == single section is promoted, text byte-identical', () => {
const out = promoteSnapshotSections(decisionWith([plainMessage('what did I decide?'), carrier('note alpha')]))
assert.equal(
out.messages.length,
1,
`the carrier must leave messages (expected 1 remaining, got ${out.messages.length})`,
)
assert.equal(
out.assembly.sections.length,
2,
`expected base + promoted section = 2, got ${out.assembly.sections.length}`,
)
const promoted = out.assembly.sections[1]
assert.equal(
promoted.text,
'note alpha',
`promoted text must be the carrier text unchanged; expected ${show('note alpha')}, got ${show(promoted.text)}`,
)
})
test('S2a: single content block carrying N sections ("\\n\\n" convention) is promoted', () => {
const out = promoteSnapshotSections(decisionWith([singleBlockCarrier(['a', 'b'], 'spaced')]))
assert.equal(
out.messages.length,
0,
`the spaced carrier must be promoted (expected 0 messages, got ${out.messages.length})`,
)
assert.equal(
out.assembly.sections.length,
2,
`expected base + promoted = 2 sections, got ${out.assembly.sections.length}`,
)
assert.equal(
out.assembly.sections[1].text,
'a\n\nb',
`promoted text must equal the content text exactly (move, not rewrite); expected ${show('a\n\nb')}, got ${show(out.assembly.sections[1].text)}`,
)
})
test('S2b: single content block carrying N sections ("" glued convention) is promoted', () => {
const out = promoteSnapshotSections(decisionWith([singleBlockCarrier(['a', 'b'], 'glued')]))
assert.equal(
out.messages.length,
0,
`the glued carrier must be promoted (expected 0 messages, got ${out.messages.length})`,
)
assert.equal(
out.assembly.sections.length,
2,
`expected base + promoted = 2 sections, got ${out.assembly.sections.length}`,
)
assert.equal(
out.assembly.sections[1].text,
'ab',
`promoted text must equal the content text exactly; expected ${show('ab')}, got ${show(out.assembly.sections[1].text)}`,
)
})
test('S2c: two content blocks == two sections (glued) is promoted', () => {
const msg = trueMultiCarrier(['a', 'b']) // 2 blocks, rendered='ab'
const out = promoteSnapshotSections(decisionWith([msg]))
assert.equal(out.messages.length, 0, `two blocks == two sections must be promoted; got ${out.messages.length} message(s)`)
assert.equal(out.assembly.sections[1].text, 'ab', `promoted text must be the rendered content; got ${JSON.stringify(out.assembly.sections[1].text)}`)
})
test('S2d: two content blocks != sections is NOT promoted (control: the match is)', () => {
// rendered = 'ab' (glued), while the single section says 'a\n\nb' — neither
// canonical join matches, so this must stay in history. The control beside it
// is the same two blocks with matching sections, which must be promoted.
const drifted = {
...trueMultiCarrier(['a', 'b']),
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'm0', text: 'a\n\nb' }] },
}
const control = trueMultiCarrier(['a', 'b'])
const out = promoteSnapshotSections(decisionWith([control, drifted]))
assert.equal(out.messages.length, 1, `only the drifted one may stay; got ${out.messages.length}`)
assert.equal(out.messages[0], drifted, 'the surviving message must be the drifted one')
})
test('S3: single content block carrying N sections != those sections is NOT promoted (control: the match is)', () => {
// Sections say "a"+"b", the content block says "zzz": editing the text would
// not be a move, so this one must stay in history — while the matching
// carrier beside it must go. The two must be told apart by CONTENT, not by
// "does it carry content at all".
const drifted = {
role: 'user',
content: [{ type: 'text', text: 'zzz' }],
source: {
kind: 'memory',
form: 'snapshot',
sections: [{ name: 'memory-0', text: 'a' }, { name: 'memory-1', text: 'b' }],
},
}
const control = singleBlockCarrier(['a', 'b'], 'spaced')
const out = promoteSnapshotSections(decisionWith([control, drifted]))
assert.equal(
out.messages.length,
1,
`only the drifted carrier may stay (expected 1 message, got ${out.messages.length})`,
)
assert.equal(out.messages[0], drifted, 'the surviving message must be the drifted carrier itself')
assert.equal(
out.assembly.sections.length,
2,
`only the control may be promoted (expected 2 sections, got ${out.assembly.sections.length})`,
)
assert.equal(
out.assembly.sections[1].text,
'a\n\nb',
`the promoted text must come from the control; expected ${show('a\n\nb')}, got ${show(out.assembly.sections[1].text)}`,
)
})
test('S4: single block != single section is NOT promoted (control: the match is)', () => {
const drifted = {
role: 'user',
content: [{ type: 'text', text: 'content says this' }],
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: 'sections say that' }] },
}
const control = carrier('note alpha')
const out = promoteSnapshotSections(decisionWith([control, drifted]))
assert.equal(out.messages.length, 1, `only the drifted carrier may stay; got ${out.messages.length} messages`)
assert.equal(out.messages[0], drifted, 'the surviving message must be the drifted carrier itself')
assert.equal(out.assembly.sections.length, 2, `only the control may be promoted; got ${out.assembly.sections.length} sections`)
assert.equal(
out.assembly.sections[1].text,
'note alpha',
`promoted text must be the control's; expected ${show('note alpha')}, got ${show(out.assembly.sections[1].text)}`,
)
})
test('S5: empty content (legacy carrier) is promoted — regression, with its real-world twin', () => {
// Two carriers: the legacy `content: []` shape (which the old code did handle)
// and the shape every real producer emits (which it did not). Both must be
// promoted, so this doubles as the regression pin for the old path AND as the
// minimal reproduction of the production no-op.
const out = promoteSnapshotSections(decisionWith([snapshotMessage('note alpha'), carrier('note beta')]))
assert.equal(
out.messages.length,
0,
`both carriers must be promoted (expected 0 messages, got ${out.messages.length})`,
)
assert.deepEqual(
out.assembly.sections.map((section) => section.text),
['base prompt', 'note alpha', 'note beta'],
`expected the legacy text first, then the real-world text; got ${show(out.assembly.sections.map((s) => s.text))}`,
)
})
test('S6: runtime-context shape (prefix in content, absent from sections) is NOT promoted', () => {
// The stock loop prepends a fixed sentence to the content and keeps `sections`
// as the raw contributions, so the two can never be equal. Promoting it would
// delete that preamble, so it must pass through — the control carrier in the
// same decision proves the comparison is on text, not on "has content".
const runtime = {
role: 'user',
content: [
{
type: 'text',
text: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\ncwd /tmp',
},
],
source: { kind: 'runtime-context', form: 'snapshot', sections: [{ name: 'runtime-context', text: 'cwd /tmp' }] },
}
const control = carrier('cwd /tmp', 'runtime-context')
const out = promoteSnapshotSections(decisionWith([control, runtime]))
assert.equal(out.messages.length, 1, `runtime-context must stay in history; got ${out.messages.length} messages`)
assert.equal(out.messages[0], runtime, 'the surviving message must be the runtime-context snapshot itself')
assert.equal(out.assembly.sections.length, 2, `only the control may be promoted; got ${out.assembly.sections.length} sections`)
assert.equal(
out.assembly.sections[1].text,
'cwd /tmp',
`promoted text must be the control's; expected ${show('cwd /tmp')}, got ${show(out.assembly.sections[1].text)}`,
)
})
test('S7: a non-text block means NOT promoted (control: the text-only one is)', () => {
const imageCarrier = {
role: 'user',
content: [{ type: 'image', text: 'note alpha' }],
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: 'note alpha' }] },
}
const badText = {
role: 'user',
content: [{ type: 'text', text: 42 }],
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: '42' }] },
}
const control = carrier('note alpha')
const out = promoteSnapshotSections(decisionWith([control, imageCarrier, badText]))
assert.equal(out.messages.length, 2, `only the text-only control may be promoted; got ${out.messages.length} messages`)
assert.equal(out.messages[0], imageCarrier, 'the image carrier must survive')
assert.equal(out.messages[1], badText, 'the non-string-text carrier must survive')
assert.equal(out.assembly.sections.length, 2, `only the control may be promoted; got ${out.assembly.sections.length} sections`)
assert.equal(
out.assembly.sections[1].text,
'note alpha',
`promoted text must be the control's; expected ${show('note alpha')}, got ${show(out.assembly.sections[1].text)}`,
)
})

836
test/memory-log.test.js Normal file
View File

@ -0,0 +1,836 @@
/**
* Observability contract for the recall log.
*
* The plugin shipped with zero `console.*` and zero `ctx.logger` calls, so a
* production recall failure left no trace anywhere: `degraded` is a return value
* the caller sees once, and the injected text only shows up if the model quotes
* it. These tests pin the replacement channel.
*
* What is pinned here, and why each assertion exists:
*
* L1 `debugLog: false` (the default) is quiet on an ordinary turn, and still
* carries every fact the operator needs when it does speak.
* L2 `debugLog: true` emits exactly ONE line covering recall / budget /
* degradation / injection.
* L3 the query TEXT is never logged — only its length.
* L4 a real fault (`embed_error`) speaks even with `debugLog` off, at `warn`.
* L5 a zero-injection turn is `debug`, not `info` — the commonest case must
* cost nothing at normal verbosity.
* L6 logging changes no behaviour: the returned text and `lastRecall` are
* identical with the summary on and off.
* L7 nothing is ever written to stdout (headless prints the ANSWER there) and
* the console fallback goes to `console.warn`, not `console.log`.
* L10 `ORDINARY_DEGRADATIONS` actually gates the level: the two healthy modes
* (`hybrid`, `no_vector_space`) are SILENT with `debugLog` off, while a
* real fault (`embed_error`) is a single `warn`. L10†/L11† mutate the set to
* prove those two assertions bite.
* L11 a fused recall whose candidates are ALL refused by `minScore` still
* reports `hybrid` with zero hits, and is still silent. `no_vector_space`
* means "there was nothing to score", never "everything was filtered" —
* collapsing the two would hide a threshold set too high.
*
* @module dsh-memory/test/memory-log
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { Context } from '@deepseek-ai/cordis'
import { Logger } from '@deepseek-ai/cordis'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
import { ToolRuntime } from '@deepseek-ai/dsh-tools'
import { apply as applyMemory, StableMem } from '../lib/index.js'
import { recallBlocksHybrid, segmentRecallBudget } from '../lib/recall.js'
import { BlockStore } from '../lib/store.js'
/**
* Build a real cordis app + plugin instance, capturing every log record.
*
* `levels: { default: 3 }` is DEBUG: cordis skips a record when
* `(exporter.levels[name] ?? default ?? logger.level ?? INFO) < level`
* (cordis/lib/index.js:470-472), so the default would silently drop `debug` and
* L5 could never observe its own subject.
*
* @param {object} config plugin config
*/
async function makeMemWith(apply, config = {}) {
const dir = mkdtempSync(join(tmpdir(), 'mlog-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
const records = []
ctx.logger.exporter({
levels: { default: 3 },
export: (message) => records.push(message),
})
const mem = apply(ctx, { path: join(dir, 'm.db'), ...config })
return {
ctx,
mem,
records,
/**
* Only this plugin's lines, rendered the way the logger actually prints them.
*
* `Logger.format` rather than a plain `args.join(' ')`: every call site emits
* `log(level)('%s', line)` so the payload cannot be reformatted (see `emit`)
* — a naive join would keep the literal `%s` prefix and hide a regression
* where the payload stopped being a single argument.
*/
lines: () =>
records
.filter((record) => record.name === 'dsh-memory')
.map((record) => ({ type: record.type, text: Logger.format({}, record) })),
async done() {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
},
}
}
/**
* The same app, built by the module under test.
*
* Parameterised by `apply` so the ORDINARY_DEGRADATIONS mutants below can run the
* IDENTICAL criterion body against a mutated `lib/index.js`. A criterion that is
* copied for the mutant can drift from the real one; a criterion that is shared
* cannot.
*
* @param {object} [config] plugin config
*/
async function makeMem(config = {}) {
return makeMemWith(applyMemory, config)
}
/** A one-message human batch. */
function ask(text) {
return [createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })]
}
/** An embedder that fails the way a dead provider does. */
const failingEmbedder = {
async embedText() {
throw new Error('read ECONNREFUSED 127.0.0.1:8081')
},
info: () => ({ fingerprint: 'fp-log', dimension: 3 }),
}
// ── L1: default config is quiet, and stays quiet for the commonest case ───
test('L1: debugLog defaults to false and an ordinary hitless turn logs nothing', async () => {
const { mem, lines, done } = await makeMem()
try {
assert.equal(mem.debugLog, false, 'the default must be false — the log is opt-in')
// No embedder is the shipping default; recall degrades to `no_embedder`,
// which is a standing configuration fact, not an event (same judgement W11e
// made for the model-facing notice).
await mem.commit({ text: '某条无关记忆' })
for (const turn of ['今天天气怎么样', '你好', '好的']) {
await mem.contextForStep(ask(turn))
}
assert.deepEqual(lines(), [], `the default must be silent; got ${JSON.stringify(lines())}`)
} finally {
await done()
}
})
// ── L2/L3: opt in, then all four fact groups appear in ONE line ───────────
test('L2: debugLog=true emits one line covering recall, budget, degradation and injection', async () => {
const { mem, lines, done } = await makeMem({ debugLog: true, topK: 3, budgetTokens: 500 })
try {
assert.equal(mem.debugLog, true, 'config.debugLog must reach the service')
await mem.commit({ text: '值班室分机号 4324,值班人 老周' })
const question = '值班室分机号是多少'
const text = await mem.contextForStep(ask(question))
assert.notEqual(text, '', 'precondition: the block must be recalled')
const emitted = lines()
assert.equal(emitted.length, 1, `exactly one line per recall turn; got ${JSON.stringify(emitted)}`)
const [record] = emitted
assert.equal(record.type, 'info', 'a turn that injected something is an info line')
// recall: length only, never the text.
assert.match(record.text, /recall query=9ch/, `query length in code points; got: ${record.text}`)
assert.ok(
!record.text.includes(question),
`the query TEXT must never be logged; got: ${record.text}`,
)
assert.match(record.text, /hits=[1-9]\d*/, 'hit count')
assert.match(record.text, /topK=3/, 'topK actually used')
assert.match(record.text, /zeroHit=no/, 'the 0-hit flag must be stated, not inferred')
// budget: which cap applied and what it retained.
assert.match(record.text, /budget=500 /, 'the explicit budget wins')
assert.match(record.text, /source=explicit/, 'source must name the rule that produced the budget')
assert.match(record.text, /kept=1\b/, 'retained count after segmenting')
assert.match(record.text, /singleHitOverBudget=(yes|no)/, 'the over-budget single-hit case is reported')
// degradation: the empty case is reported as healthy, explicitly.
assert.match(record.text, /degraded=no_embedder/, 'the mode is named, not omitted')
assert.match(record.text, /skipped=0 skippedDim=0/, 'skip counters')
// D2: the three downstream counters must be PRINTED, not merely computed.
// A counter nobody can read is indistinguishable from no counter at all.
assert.match(record.text, /skippedScore=0 skippedNoEvidence=0 skippedUnscoreable=0 truncated=0/, 'downstream drop counters')
assert.match(record.text, /err=-/, 'no error digest when there is no error')
// injection: the exact string length that reached the model.
assert.match(record.text, new RegExp(`inject=${[...text].length}ch`), 'injected length')
} finally {
await done()
}
})
test('L3: the budget source is named for each precedence rule', async () => {
// explicit > host window > fallback, read straight off the summary line.
const explicit = await makeMem({ debugLog: true, budgetTokens: 1234 })
try {
await explicit.mem.commit({ text: '值班室分机号 4324' })
await explicit.mem.contextForStep(ask('值班室分机号是多少'))
assert.match(explicit.lines()[0].text, /budget=1234 source=explicit/)
} finally {
await explicit.done()
}
const host = await makeMem({ debugLog: true })
try {
await host.mem.commit({ text: '值班室分机号 4324' })
// A host window of 100 tokens → 80% = 80 token budget.
await host.mem.contextForStep(ask('值班室分机号是多少'), undefined, { contextWindow: 100 })
assert.match(host.lines()[0].text, /budget=80 source=host/)
} finally {
await host.done()
}
const fallback = await makeMem({ debugLog: true })
try {
await fallback.mem.commit({ text: '值班室分机号 4324' })
await fallback.mem.contextForStep(ask('值班室分机号是多少'))
assert.match(fallback.lines()[0].text, /budget=4096 source=fallback/)
} finally {
await fallback.done()
}
})
// ── L2b: non-zero drop counters reach BOTH operator surfaces ──────────────
//
// L2 only proves the fields are printed when everything is zero. A counter that
// is correct in the returned array but lost on the way to the log is still
// invisible — and `segmentRecallBudget` slices that array, which is exactly where
// non-enumerable properties normally disappear.
//
// Fixture: three blocks on separate axes, a query mostly on axis 0. Cosines are
// ~0.95 (passes), ~0.30 (gated by the 0.5 threshold) and 0 (nothing to score),
// so the three destinations are all populated at once.
test('L2b: non-zero drop counters appear in the log and in last_recall', async () => {
const DIM = 4
const unit = (v) => { const n = Math.hypot(...v); return n === 0 ? v : v.map((x) => x / n) }
const axis = (i) => unit(Array.from({ length: DIM }, (_, k) => (k === i ? 1 : 0)))
const QUERY = unit([0.95, 0.30, 0, 0])
const byText = new Map([
['值班室的分机号是 4324', axis(0)],
['食堂在东楼二层', axis(1)],
['叉车的额定载重是两吨', axis(2)],
])
const embedder = {
async embedText(text) {
if (text === '分机号是多少') return QUERY
return byText.get(text) ?? new Array(DIM).fill(0)
},
info: () => ({ fingerprint: 'd2:nonzero', dimension: DIM }),
}
const { mem, lines, done } = await makeMemWith(applyMemory, { debugLog: true, embedder })
try {
for (const text of byText.keys()) await mem.commit({ text })
await mem.contextForStep(ask('分机号是多少'))
const emitted = lines()
assert.equal(emitted.length, 1, `exactly one line per recall turn; got ${JSON.stringify(emitted)}`)
assert.match(
emitted[0].text,
/skippedScore=1 skippedNoEvidence=1 skippedUnscoreable=0 truncated=0/,
`D2: the counters must survive segmenting and reach the log; got ${emitted[0].text}`,
)
assert.equal(mem.lastRecall.skipped_score, 1, 'and the durable surface must agree')
assert.equal(mem.lastRecall.skipped_no_evidence, 1, 'both silent-drop kinds reported separately')
assert.equal(mem.lastRecall.truncated, 0, 'nothing was cut by topK here')
} finally {
await done()
}
})
// ── L4: a real fault speaks with the log switched off ─────────────────────
test('L4: an embed_error is warned about even when debugLog is false', async () => {
const { mem, lines, done } = await makeMem({ embedder: failingEmbedder })
try {
assert.equal(mem.debugLog, false)
await mem.commit({ text: '值班室分机号 4324' })
// `commit` also embeds, so clear the records that produced to keep the
// assertion about the RECALL line unambiguous.
lines().length = 0
await mem.contextForStep(ask('值班室分机号是多少'))
const recalled = lines().filter((line) => line.text.startsWith('recall '))
assert.equal(recalled.length, 1, `a fault must produce exactly one recall line; got ${JSON.stringify(recalled)}`)
assert.equal(recalled[0].type, 'warn', 'a failed search is a warn, not an info/debug')
assert.match(recalled[0].text, /degraded=embed_error/)
assert.match(
recalled[0].text,
/err=read ECONNREFUSED 127\.0\.0\.1:8081/,
`the error digest must name the cause; got: ${recalled[0].text}`,
)
} finally {
await done()
}
})
test('L4b: a long provider error is flattened and truncated, never a multi-line log', async () => {
const verbose = {
async embedText() {
throw new Error(`connection refused\n at socket.js:1\n${'x'.repeat(400)}`)
},
info: () => ({ fingerprint: 'fp-log2', dimension: 3 }),
}
const { mem, lines, done } = await makeMem({ embedder: verbose })
try {
await mem.contextForStep(ask('值班室分机号是多少'))
const recalled = lines().filter((line) => line.text.startsWith('recall '))
assert.equal(recalled.length, 1)
assert.ok(!recalled[0].text.includes('\n'), 'a log line must be one line')
assert.ok(recalled[0].text.includes('...'), 'the digest must be truncated')
assert.ok(
recalled[0].text.length < 400,
`the digest must be bounded; got ${recalled[0].text.length} chars`,
)
} finally {
await done()
}
})
// ── L5: the commonest case is `debug`, not `info` ────────────────────────
test('L5: a zero-injection turn logs exactly one DEBUG line, not info', async () => {
const { mem, lines, done } = await makeMem({ debugLog: true })
try {
await mem.commit({ text: '某条无关记忆' })
const text = await mem.contextForStep(ask('今天天气怎么样'))
assert.equal(text, '', 'precondition: nothing is injected')
const emitted = lines()
assert.equal(emitted.length, 1, `still exactly one line; got ${JSON.stringify(emitted)}`)
assert.equal(emitted[0].type, 'debug', 'zero injection must not be an info line')
assert.match(emitted[0].text, /zeroHit=yes/, 'the 0-hit case must say so')
assert.match(emitted[0].text, /inject=0ch/)
} finally {
await done()
}
})
test('L5b: a batch with no human prompt logs one debug line and recalls nothing', async () => {
const { mem, lines, done } = await makeMem({ debugLog: true })
try {
// This is the shape that once made every real turn recall the skill catalog,
// so "there was no human prompt" is worth a line of its own.
const injected = await mem.contextForStep([
createUserMessage({
content: [{ type: 'text', text: '[Skills]\n- alpha\n- beta' }],
source: { kind: 'skill-catalog', form: 'snapshot' },
}),
])
assert.equal(injected, '', 'an injected snapshot is not a recall query')
const emitted = lines()
assert.equal(emitted.length, 1)
assert.equal(emitted[0].type, 'debug')
assert.match(emitted[0].text, /query=0ch/)
assert.match(emitted[0].text, /skipped=no-human-prompt/)
} finally {
await done()
}
})
// ── L6: observability must be inert ──────────────────────────────────────
test('L6: turning the log on changes neither the injected text nor lastRecall', async () => {
const run = async (debugLog) => {
const app = await makeMem({ debugLog })
try {
await app.mem.commit({ text: '值班室分机号 4324,值班人 老周' })
const text = await app.mem.contextForStep(ask('值班室分机号是多少'), undefined, {
contextWindow: 2000,
})
const { at, ...lastRecall } = app.mem.stats().last_recall ?? {}
void at
return { text, lastRecall }
} finally {
await app.done()
}
}
const quiet = await run(false)
const loud = await run(true)
assert.equal(loud.text, quiet.text, 'the injected text must be byte-identical')
assert.notEqual(quiet.text, '', 'precondition: something was injected')
assert.deepEqual(loud.lastRecall, quiet.lastRecall, 'the stats surface must be unchanged')
})
// ── L7: stdout stays clean; the fallback shim goes to warn ───────────────
test('L7: logging never touches stdout, and the console fallback uses console.warn', async () => {
const stdout = []
const warned = []
const errored = []
const originals = {
log: console.log,
warn: console.warn,
error: console.error,
}
console.log = (...args) => stdout.push(args)
console.warn = (...args) => warned.push(args)
console.error = (...args) => errored.push(args)
const { mem, done } = await makeMem({ debugLog: true })
let fallbackCtx = null
let fallbackStore = null
try {
await mem.commit({ text: '值班室分机号 4324' })
await mem.contextForStep(ask('值班室分机号是多少'))
assert.deepEqual(
stdout,
[],
`the real logger path must never write stdout (headless prints the ANSWER there); got ${JSON.stringify(stdout)}`,
)
// Now the fallback: a context whose logger service cannot produce a facade.
// `resolveLogger` must degrade to console.warn rather than throw.
fallbackCtx = new Context()
fallbackCtx.logger = () => {
throw new Error('no logger service')
}
fallbackStore = new BlockStore({ path: ':memory:' }).open()
const fallback = new StableMem(fallbackCtx, { store: fallbackStore, debugLog: true })
fallback.log.warn('%s', 'shim warn')
fallback.log.debug('%s', 'shim debug')
fallback.log.error('%s', 'shim error')
assert.deepEqual(stdout, [], `stdout must stay clean; got ${JSON.stringify(stdout)}`)
assert.deepEqual(
warned.map((args) => args.join(' ')),
['shim warn', 'shim debug'],
'the fallback routes both debug and info to console.warn',
)
assert.deepEqual(errored.map((args) => args.join(' ')), ['shim error'])
} finally {
console.log = originals.log
console.warn = originals.warn
console.error = originals.error
fallbackStore?.close()
await fallbackCtx?.fiber.dispose()
await done()
}
})
// ── L4†: a mutation, not a self-test ─────────────────────────────────────
//
// "A guard that cannot fail is not a guard" — the same rule W11d†/W11e† follow in
// phase-B-H. This one patches `lib/index.js` on a COPY, imports it fresh, and
// asserts the behaviour flips. If the anchor moves, the split-count assertion
// fails loudly instead of the test silently passing against unmutated source.
test('L4†: silencing the fault branch turns L4 red', async () => {
const { readFileSync, writeFileSync, mkdtempSync, rmSync, cpSync } = await import('node:fs')
const { tmpdir } = await import('node:os')
const { join } = await import('node:path')
const { fileURLToPath, pathToFileURL } = await import('node:url')
const libDir = fileURLToPath(new URL('../lib/', import.meta.url))
const pkgRoot = fileURLToPath(new URL('..', import.meta.url))
const dir = mkdtempSync(join(tmpdir(), 'mlog-mut-'))
try {
cpSync(libDir, join(dir, 'lib'), { recursive: true })
cpSync(join(pkgRoot, 'package.json'), join(dir, 'package.json'))
try {
cpSync(join(pkgRoot, 'node_modules'), join(dir, 'node_modules'), {
recursive: true,
dereference: false,
})
} catch {
// Poetry-free install: the body below will fail loudly, which is correct.
}
const target = join(dir, 'lib', 'index.js')
const source = readFileSync(target, 'utf8')
const anchor = ' if (this.debugLog !== true && !fault) return'
assert.equal(
source.split(anchor).length - 1,
1,
'L4† anchor must occur exactly once — did the gating line move?',
)
writeFileSync(target, source.replace(anchor, ' if (this.debugLog !== true) return // MUTATED'))
const mod = await import(pathToFileURL(target).href)
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
const records = []
ctx.logger.exporter({ levels: { default: 3 }, export: (message) => records.push(message) })
try {
const mem = mod.apply(ctx, {
path: join(dir, 'm.db'),
embedder: failingEmbedder,
})
await mem.contextForStep(ask('值班室分机号是多少'))
const recalled = records.filter(
(record) => record.name === 'dsh-memory' && String(record.args[1] ?? '').startsWith('recall '),
)
assert.deepEqual(
recalled,
[],
'under the mutation a fault is silent with debugLog off, so L4 must fail',
)
} finally {
await ctx.fiber.dispose()
}
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
// ── L8: the line must actually be READABLE in a real process ──────────────
//
// Measured: cordis ships one default exporter (an in-memory ring buffer) and no
// dsh bundle installs a terminal one, so `ctx.logger.info(...)` in a real profile
// lands in a buffer nobody drains. A "we added logging" change that only reaches
// that buffer would be green here and still print nothing in `dsh headless`.
// So this asserts on process.stderr, which is where an operator can see it.
test('L8: the summary reaches stderr, gated by debugLog, and never stdout', async () => {
const originalWrite = process.stderr.write
const stderr = []
const stdout = []
const originalOutWrite = process.stdout.write
process.stderr.write = (chunk, ...rest) => {
stderr.push(String(chunk))
return originalWrite.call(process.stderr, chunk, ...rest)
}
process.stdout.write = (chunk, ...rest) => {
stdout.push(String(chunk))
return originalOutWrite.call(process.stdout, chunk, ...rest)
}
const runTurn = async (config) => {
stderr.length = 0
const app = await makeMem(config)
try {
await app.mem.commit({ text: '值班室分机号 4324,值班人 老周' })
stderr.length = 0 // drop anything the commit produced
await app.mem.contextForStep(ask('值班室分机号是多少'))
return stderr.join('')
} finally {
await app.done()
}
}
try {
const loud = await runTurn({ debugLog: true })
assert.match(loud, /^dsh-memory: info: recall query=9ch/m, `want one info line on stderr; got: ${JSON.stringify(loud)}`)
const quiet = await runTurn({})
assert.equal(quiet, '', `debugLog=false must print nothing for a healthy turn; got: ${JSON.stringify(quiet)}`)
assert.deepEqual(stdout, [], `stdout must never be written; got ${JSON.stringify(stdout)}`)
} finally {
process.stderr.write = originalWrite
process.stdout.write = originalOutWrite
}
})
test('L8b: foreign loggers are not reprinted under this plugin’s prefix', async () => {
// The exporters map is process-wide, so a sink without a name check would
// duplicate every other plugin's warnings — turning an observability change
// into an amplification bug.
const originalWrite = process.stderr.write
const stderr = []
process.stderr.write = (chunk, ...rest) => {
stderr.push(String(chunk))
return originalWrite.call(process.stderr, chunk, ...rest)
}
const { ctx, mem, done } = await makeMem({ debugLog: true })
try {
stderr.length = 0
ctx.logger('some-other-plugin').warn('%s', 'not mine')
ctx.logger('some-other-plugin').info('%s', 'also not mine')
void mem
assert.equal(stderr.join(''), '', `another plugin's records must not be reprinted; got: ${JSON.stringify(stderr)}`)
} finally {
process.stderr.write = originalWrite
await done()
}
})
// ══════════════════════════════════════════════════════════════════════════════
// L10 · ORDINARY_DEGRADATIONS must actually DECIDE the level
// ══════════════════════════════════════════════════════════════════════════════
//
// P1 added two healthy modes to `lib/recall.js` (`hybrid`, `no_vector_space`) and
// registered both in `ORDINARY_DEGRADATIONS`. Nothing tested that registration:
// `grep ORDINARY_DEGRADATIONS test/` was empty, and every P1-era assertion checked
// the LABEL (`hits.degraded === 'hybrid'`), never the LOG LEVEL. Deleting `'hybrid'`
// from the set therefore kept all 268 tests green while a healthy wired recall
// started emitting `warn` on every turn — a false fault alarm on the happiest path.
//
// The three cases below are deliberately in ONE group so the contrast is visible:
// two healthy modes must be silent, one real fault must speak, all at the same
// verbosity (`debugLog: false`, the shipping default).
/**
* An embedder whose vectors put every block and the query on the same axis.
*
* Deterministic and transport-free: "a vector exists in this space" is the only
* fact these cases need, and a real provider would add neither coverage nor
* determinism.
*
* @param {string} fingerprint
*/
function fixedEmbedder(fingerprint) {
return {
async embedText() { return [1, 0, 0] },
info: () => ({ fingerprint, dimension: 3 }),
}
}
const L10_SPACE = 'fp-l10'
/**
* Seed one block that carries a vector in the active space.
*
* The explicit vector+fingerprint pair goes through the documented
* `commit({ vector, fingerprint })` path, so the write is a normal store write
* rather than a fixture shortcut: `recallBlocksHybrid` sees a real row.
*
* @param {{mem: object}} app
* @param {string} text
*/
async function commitVectored(app, text) {
await app.mem.commit({ text, vector: [1, 0, 0], fingerprint: L10_SPACE })
}
/** The criterion, shared by the real module and every mutant. */
async function assertLevelContract(build) {
// ── (1) `hybrid` is silent at the default verbosity ─────────────────────
//
// Preconditions asserted, not assumed: the space really holds a comparable
// vector, the hit really carries a vector contribution, and the label really is
// `hybrid`. Without them a silent log would be indistinguishable from a build
// that never reached the vector route at all.
const hybridApp = await build({ embedder: fixedEmbedder(L10_SPACE) })
let hybridText
let hybridHits
try {
await commitVectored(hybridApp, '值班室分机号 4324')
hybridText = await hybridApp.mem.contextForStep(ask('值班室分机号是多少'))
hybridHits = hybridApp.mem.lastRecall
const emitted = hybridApp.lines()
assert.equal(hybridHits?.mode, 'hybrid', `precondition: the wired route must be labelled hybrid; got ${JSON.stringify(hybridHits)}`)
assert.notEqual(hybridText, '', 'precondition: the block must actually be recalled')
assert.deepEqual(
emitted,
[],
'L10.1: a HEALTHY wired recall must log nothing with debugLog off. ' +
'A `warn` here is a false fault alarm on the happiest path, every turn. ' +
`got ${JSON.stringify(emitted)}`,
)
} finally {
await hybridApp.done()
}
// ── (2) `no_vector_space` is silent too ─────────────────────────────────
//
// A configured embedder whose space holds no usable vector. This is a DATA
// state (fresh space), not a provider fault, so it must not warn either.
//
// ★ The block must be written while NO embedder is mounted, otherwise `commit`
// embeds it and the space is no longer empty — a fixture that quietly fills the
// space it is supposed to leave bare would make this case assert nothing (it did,
// on the first attempt: the label came back `hybrid`).
const emptyApp = await build({})
try {
await emptyApp.mem.commit({ text: '值班室分机号 4324' })
emptyApp.mem.embedder = fixedEmbedder('fp-l10-empty')
await emptyApp.mem.contextForStep(ask('值班室分机号是多少'))
const mode = emptyApp.mem.lastRecall?.mode
assert.equal(mode, 'no_vector_space', `precondition: an empty space must report no_vector_space; got ${mode}`)
assert.deepEqual(
emptyApp.lines(),
[],
'L10.2: `no_vector_space` is a standing data state, not a fault — it must not warn. ' +
`got ${JSON.stringify(emptyApp.lines())}`,
)
} finally {
await emptyApp.done()
}
// ── (3) `embed_error` DOES speak, at `warn`, exactly once ───────────────
//
// The control. Without it, "silent" could be satisfied by a build that never
// logs at all — which is the bug the whole log channel exists to fix (L4).
const faultApp = await build({ embedder: failingEmbedder })
try {
await faultApp.mem.commit({ text: '值班室分机号 4324' })
await faultApp.mem.contextForStep(ask('值班室分机号是多少'))
const recalled = faultApp.lines().filter((line) => line.text.startsWith('recall '))
assert.equal(recalled.length, 1, `L10.3: a fault must produce exactly one recall line; got ${JSON.stringify(recalled)}`)
assert.equal(recalled[0].type, 'warn', `L10.3: a fault is a warn; got ${recalled[0].type}`)
assert.match(recalled[0].text, /degraded=embed_error/, 'and names the fault')
} finally {
await faultApp.done()
}
}
test('L10: healthy modes are silent and faults warn — the same verbosity, three outcomes', async () => {
await assertLevelContract(makeMem)
})
// ══════════════════════════════════════════════════════════════════════════════
// L11 · `minScore` filtering everything is NOT `no_vector_space`
// ══════════════════════════════════════════════════════════════════════════════
//
// Measured behaviour (pinned because "everything was filtered out" looks like a
// bug to a future reader, and the tempting fix — call it `no_vector_space` — would
// erase the distinction between "there was nothing to score" and "the threshold is
// too high"). `no_vector_space` is returned at exactly one place,
// `recall.js`'s `vectorScores.size === 0`; a fused run that produced candidates
// and then gated them all keeps the `hybrid` label with `hits: []`.
test('L11: a fused recall gated to zero hits by minScore still reports hybrid, and stays silent', async () => {
const { mem, lines, done } = await makeMem({ embedder: fixedEmbedder(L10_SPACE) })
try {
await commitVectored({ mem }, '值班室分机号 4324')
const question = '值班室分机号是多少'
// ★ `minScore` is applied PER PATH against the RAW VECTOR score (V5), not
// against the fused 0.7. The block's vector score is 1.0, so the threshold
// that removes it has to exceed 1.0.
//
// ★ Driven through `recallBlocksHybrid` rather than `mem.recall`: the
// service method forwards only `topK`/`signal`, so a threshold passed there
// would never reach the scorer and this precondition would silently pass on an
// unchanged result.
const raw = await recallBlocksHybrid(mem.store, fixedEmbedder(L10_SPACE), question, {
topK: 3,
minScore: 1.5,
})
assert.deepEqual(
[...raw].map((hit) => hit.text),
[],
`precondition: the threshold must remove every candidate; got ${JSON.stringify(
[...raw].map((hit) => hit.text),
)}`,
)
assert.equal(
raw.degraded,
'hybrid',
`a gated recall is NOT an empty space: the vector route did score candidates. Got ${raw.degraded}`,
)
assert.equal(raw.degradedError, undefined, 'and it carries no error')
// The log decision is the subject of L10, so drive the real method with the
// real gated result. Nothing here is a stub: `logRecallTurn` is what the
// plugin calls on every turn.
mem.logRecallTurn({
query: question,
messages: ask(question),
raw,
segmented: segmentRecallBudget(raw, { topK: 3 }),
injected: '',
})
assert.deepEqual(
lines(),
[],
`L11: a hybrid recall with zero surviving hits must still be silent with debugLog off; got ${JSON.stringify(lines())}`,
)
} finally {
await done()
}
})
// ══════════════════════════════════════════════════════════════════════════════
// L10† / L11† · the mutations that make the two silent assertions bite
// ══════════════════════════════════════════════════════════════════════════════
/**
* Copy the package, rewrite ONE anchor in `lib/index.js`, import it fresh.
*
* Same shape as L4† (and the mutation discipline note in docs/phase-b-criteria.md):
* the anchor must occur exactly once, or the test fails loudly rather than
* silently asserting against unmutated source.
*
* @param {string} anchor exact source text, expected once
* @param {string} replacement
* @param {(apply: Function) => Promise<void>} body receives the mutated `apply`
*/
async function withMutatedIndex(anchor, replacement, body) {
const { readFileSync, writeFileSync, mkdtempSync, rmSync, cpSync } = await import('node:fs')
const { tmpdir } = await import('node:os')
const { join } = await import('node:path')
const { fileURLToPath, pathToFileURL } = await import('node:url')
const libDir = fileURLToPath(new URL('../lib/', import.meta.url))
const pkgRoot = fileURLToPath(new URL('..', import.meta.url))
const dir = mkdtempSync(join(tmpdir(), 'mlog-l8-'))
try {
cpSync(libDir, join(dir, 'lib'), { recursive: true })
cpSync(join(pkgRoot, 'package.json'), join(dir, 'package.json'))
try {
cpSync(join(pkgRoot, 'node_modules'), join(dir, 'node_modules'), {
recursive: true,
dereference: false,
})
} catch {
// A poetry-free install fails loudly in the body below, which is correct.
}
const target = join(dir, 'lib', 'index.js')
const source = readFileSync(target, 'utf8')
assert.equal(
source.split(anchor).length - 1,
1,
`mutation anchor must occur exactly once — did the set move?\n${anchor}`,
)
writeFileSync(target, source.replace(anchor, replacement))
const mod = await import(pathToFileURL(target).href)
await body((ctx, config) => mod.apply(ctx, config))
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
for (const [name, removed, bites] of [
['L10\u2020', "'hybrid'", 'L10.1'],
['L11\u2020', "'no_vector_space'", 'L10.2'],
]) {
test(`${name}: removing ${removed} from ORDINARY_DEGRADATIONS turns ${bites} red`, async (t) => {
const anchor = " 'ok', 'hybrid', 'no_embedder', 'aborted', 'no_vector_space', 'vector_ready_unused',"
const replacement = [
' // MUTATED: one healthy mode de-registered',
" 'ok', 'no_embedder', 'aborted',",
removed === "'hybrid'" ? " 'no_vector_space', 'vector_ready_unused'," : " 'hybrid', 'vector_ready_unused',",
].join('\n')
await withMutatedIndex(anchor, replacement, async (apply) => {
// The SAME criterion body the unmutated build passes. It must throw, and the
// throw must be the assertion's own — a different error (a bad import, a
// missing node_modules) would make this test green for the wrong reason.
await assert.rejects(
() => assertLevelContract((config) => makeMemWith(apply, config)),
(error) => {
assert.equal(error.code, 'ERR_ASSERTION', `only the criterion may bite; got ${error.code}: ${error.message}`)
assert.ok(
String(error.message).startsWith(bites),
`the biting assertion must be ${bites}; got: ${String(error.message).split('\n')[0]}`,
)
t.diagnostic(`${name} RAW RED >>> ${String(error.message).split('\n')[0]}`)
return true
},
`${name}: with ${removed} de-registered, ${bites} must go red`,
)
})
})
}

101
test/packaging.test.js Normal file
View File

@ -0,0 +1,101 @@
/**
* 打包完整性:`npm pack` 的产物必须能真的被加载。
*
* ## 这条判据为什么存在
*
* `package.json` 的 `files` 原本是
*
* ["lib/*.js", "lib/loop.js", "lib/loop/*.js", "lib/types/*.d.ts"]
*
* 而 `lib/*.js` 只匹配 lib/ 下一层。于是 `lib/embed/http.js` —— `lib/index.js`
* 第 35 行 `import { createHttpEmbedder } from './embed/http.js'` 的目标 ——
* 从来没有被打进包。
*
* 这个缺陷活下来的原因很具体:`npm pack --dry-run` 一直打印「10 files」,
* 数字看起来正常,没有人去读那十个文件名;而仓库里所有测试都是从源码树跑
* 的,源码树里那个文件当然存在。**只有在「从 tarball 安装」这条路径上才会
* 暴露**,而那正是发布出去的那条路径。
*
* 实测(修复前,把 tarball 解开后 import):
*
* IMPORT FAILED: ERR_MODULE_NOT_FOUND
* Cannot find module '.../package/lib/embed/http.js' imported from '.../package/lib/index.js'
*
* 修好之后同一条命令:`IMPORT OK, exports: StableMem,apply,default,...`
*
* ## 判据形态
*
* 静态、无副作用、不跑 npm:把 `files` 里的每个模式编译成正则,然后要求
* **lib/ 下的每一个 .js 都至少被一个模式匹配**。运行时需要的文件全在 lib/ 下,
* 所以这条覆盖了「漏文件」这一类,不需要维护一份白名单。
*
* 另加一条:`lib/index.js` 里所有相对 import 的目标必须存在于源码树 ——
* 否则上面那条可能在「文件根本不存在」时也绿(模式匹配不到不存在的文件)。
*
* @module dsh-memory/test/packaging
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
const ROOT = new URL('..', import.meta.url)
/** 把 npm `files` 里的一条模式编译成正则(`*` 不跨目录,与 npm 一致)。 */
function patternToRegExp(pattern) {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')
return new RegExp(`^${escaped}$`)
}
/** lib/ 下所有 .js 文件的相对路径。 */
function libFiles() {
const out = []
const walk = (relative) => {
for (const entry of readdirSync(new URL(relative + '/', ROOT), { withFileTypes: true })) {
const path = `${relative}/${entry.name}`
if (entry.isDirectory()) walk(path)
else if (entry.name.endsWith('.js')) out.push(path)
}
}
walk('lib')
return out.sort()
}
test('packaging: every file under lib/ is covered by package.json "files"', () => {
const pkg = JSON.parse(readFileSync(new URL('package.json', ROOT), 'utf8'))
assert.ok(Array.isArray(pkg.files), 'package.json must declare a "files" allowlist')
const patterns = pkg.files.map(patternToRegExp)
const missing = libFiles().filter((path) => !patterns.some((re) => re.test(path)))
assert.deepEqual(
missing,
[],
'these files live under lib/ but no "files" pattern reaches them, so they will be '
+ 'absent from the published tarball. `lib/*.js` matches ONE level only — a module '
+ 'in a subdirectory needs its own pattern. This is how lib/embed/http.js went '
+ 'missing while `npm pack --dry-run` happily reported a count:\n '
+ missing.join('\n '),
)
})
test('packaging: every relative import in lib/index.js resolves on disk', () => {
const source = readFileSync(new URL('lib/index.js', ROOT), 'utf8')
const specifiers = [...source.matchAll(/from\s+'(\.[^']+)'/g)].map((m) => m[1])
assert.ok(specifiers.length > 0, 'precondition: lib/index.js must have relative imports')
const unresolved = specifiers.filter((specifier) => {
try {
const url = new URL(specifier, new URL('lib/index.js', ROOT))
readFileSync(url)
return false
} catch {
return true
}
})
assert.deepEqual(
unresolved,
[],
`lib/index.js imports modules that do not exist: ${unresolved.join(', ')}`,
)
})

1092
test/phase-b-a.test.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,751 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { BlockStore } from '../lib/store.js'
import { recallBlocksHybrid } from '../lib/recall.js'
// ══════════════════════════════════════════════════════════════════════════
// C4 · 中心化校正(graph_centroid)
// ══════════════════════════════════════════════════════════════════════════
//
// 判据来源 docs/phase-b-criteria.md:253
// C4 校正时查询向量与块向量都去均值,不能只处理一边
// 依据 centerBoth(centroid_store.go:443-451)
// + 注释「只处理一边等于混两种量纲」
//
// ★ 为什么必须自建空间,不能复用 P1 的语料
// P1 的假 embedder 是 8 族正交 one-hot:每条块独占一维,均值向量趋近 0
// (实测 mean norm = 0.3536 < MEAN_THRESHOLD 0.6)⇒ isAnomalous = false
// ⇒ 正确门控的 C4 会自动关闭去均值 ⇒ 判据全绿但什么都没验到。
// 所以这里手工构造各向异性空间:所有块共享一个强背景方向,
// 各自只叠一个小 offset,均值范数才会高到触发校正。
//
// ★ 三条判据的分工(别混)
// C4a born-RED 未实现时红(裸余弦 top1 = hub,去均值后应为 target)
// C4b mutation-GUARD 未实现时绿(裸余弦下同值对本来就 = 1),
// 只有「只减一边」能让它红 —— 抓 C4 的病根。
// 实测:只减 query 时 C4a 的 top1 仍然是 target,
// 所以 C4a 抓不到这个错误,必须靠 C4b。
// C4c guard 无中心时照常返回结果,只是不校正
//
// ★ What this does NOT establish
// 中心化改变的是向量间的夹角,因此能改变排序。
// 它不能让一个没校准的绝对分数变得可用:去均值之后余弦仍然是余弦,
// 而 DEFAULT_MIN_SCORE = 0.5 从来不是从中心化后的分布推出来的。
// 阈值校准仍是 phase-b-p1-vector-wiring.test.js:51-58 记录的已知未解问题。
const DIM = 8
const SPACE = 'c4:centering:test'
// ── 空间常数(钉死;改一个就必须重测翻转窗口)────────────────────────────
const HUB_EPS = 0.02
const TARGET_EPS = 0.30
const QUERY_WEIGHT = 0.40
const MEAN_THRESHOLD = 0.6
const norm = (v) => Math.sqrt(v.reduce((s, x) => s + x * x, 0))
const unit = (v) => { const n = norm(v); return n === 0 ? v : v.map((x) => x / n) }
const axis = (i) => unit(Array.from({ length: DIM }, (_, k) => (k === i ? 1 : 0)))
// 共同背景方向:所有块都携带它,这是各向异性的来源
const BACKBONE = unit([1.0, 0.55, 0.42, 0.30, 0.22, 0.16, 0.11, 0.07])
const TARGET_AXIS = axis(7)
const mk = (eps, ax) => unit(BACKBONE.map((x, i) => x + eps * ax[i]))
const HUB_VEC = mk(HUB_EPS, axis(1))
const TARGET_VEC = mk(TARGET_EPS, TARGET_AXIS)
const DISTRACTOR_VECS = [0, 2, 3, 4, 5, 6].map((i) => mk(TARGET_EPS, axis(i)))
const QUERY_TEXT = '上次说的那个四位数是什么来着?'
const QUERY_VEC = unit(BACKBONE.map((x, i) => x + TARGET_EPS * QUERY_WEIGHT * TARGET_AXIS[i]))
const HUB = { key: 'hub', text: '这段时间一切照旧,没有什么特别的事情。', vec: HUB_VEC }
const TARGET = { key: 'target', text: '第七号仓库的备用门禁码是 QP47-812。', vec: TARGET_VEC }
const DISTRACTORS = DISTRACTOR_VECS.map((vec, i) => ({
key: `d${i}`, text: `干扰块 ${i}:与本问无关的日常事项。`, vec,
}))
const CORPUS = [HUB, TARGET, ...DISTRACTORS]
const byText = new Map(CORPUS.map((b) => [b.text, b.vec]))
function makeEmbedder() {
return {
async embedText(text) {
if (byText.has(text)) return byText.get(text)
if (text === QUERY_TEXT) return QUERY_VEC
return new Array(DIM).fill(0)
},
info: () => ({ fingerprint: SPACE, dimension: DIM }),
}
}
/** 建一个装满 CORPUS 的库,返回 store 与 id→key 的映射。 */
function seedStore({ withCentroid = true } = {}) {
const store = new BlockStore({ path: ':memory:' }).open()
const keyOfId = new Map()
for (const block of CORPUS) {
const written = store.putBlock({ text: block.text, vector: block.vec, fingerprint: SPACE })
keyOfId.set(written.id, block.key)
}
if (withCentroid) store.rebuildCentroid(SPACE)
return { store, keyOfId }
}
/** 召回并按 key 呈现,便于断言。 */
async function recallByKey(store, keyOfId, options = {}) {
const hits = await recallBlocksHybrid(store, makeEmbedder(), QUERY_TEXT, { topK: 8, ...options })
return hits.map((hit) => ({ key: keyOfId.get(hit.id) ?? hit.id, score: hit.score, vectorHit: hit.vectorHit }))
}
const fmt = (hits) => hits.map((h) => `${h.key}=${h.score.toFixed(4)}(v=${Number(h.vectorHit ?? 0).toFixed(4)})`).join(' ')
// ── 前置:空间真的会触发校正 ────────────────────────────────────────────
//
// 没有这三条,后面任何断言都可能因为「校正压根没启用」而通过,那是假绿。
test('C4-0: the fixture space is anisotropic enough to enable centering', () => {
const { store } = seedStore()
try {
const centroid = store.loadCentroid(SPACE)
assert.ok(centroid !== undefined, 'precondition: a centroid must exist after rebuildCentroid')
assert.equal(centroid.isAnomalous, true, 'precondition: the space must be judged anisotropic')
assert.ok(
norm(centroid.mean) >= MEAN_THRESHOLD,
`precondition: mean norm ${norm(centroid.mean).toFixed(6)} must clear MEAN_THRESHOLD ${MEAN_THRESHOLD}`,
)
assert.equal(centroid.dim, DIM, 'precondition: centroid dimension must match the space')
assert.equal(centroid.vectorCount, CORPUS.length, 'precondition: every block contributed')
} finally {
store.close()
}
})
// ── C4a: 校正把 top-1 从 hub 翻到 target ────────────────────────────────
//
// born-RED:在有中心但不去均值的构建上,裸余弦会让 hub 赢
// (实测 hub 0.9928 > target 0.9859),因为 hub 几乎就是背景本身,
// 而背景是 query 与所有块共有的最大分量。
test('C4a: centering flips top-1 from the hub to the target', async (t) => {
const { store, keyOfId } = seedStore()
try {
const hits = await recallByKey(store, keyOfId)
t.diagnostic(`C4a ranking: ${fmt(hits)}`)
assert.ok(hits.length > 0, 'precondition: the recall must return something')
assert.equal(
hits[0].key,
'target',
'C4a: after centering the target must be top-1. ' +
'If this is "hub", the space mean is still dominating the score — either ' +
'centering is not wired at all, or only one side was centered. ' +
`got ${fmt(hits)}`,
)
// 分数也要过闸,否则「top1 对了」但整轮被 minScore 清空就成了空断言
assert.ok(
hits[0].score > 0.5,
`C4a: the winning score must clear DEFAULT_MIN_SCORE 0.5; got ${hits[0].score}`,
)
} finally {
store.close()
}
})
// ── C4b: 同值对的余弦恒为 1(mutation-GUARD)──────────────────────────────
//
// 这是唯一一条**不依赖语料构造、纯代数必然成立**的性质:
// 若块向量与查询向量完全相同,余弦必须恰好是 1 —— 不论是否中心化。
// · 两侧都减同一个均值 ⇒ (v-m) 与 (v-m) 的余弦 = 1 ✓
// · 只减一边 ⇒ (v-m) 与 v 的余弦 ≠ 1 ✗ ← M-C4 正是这个
//
// ★ 它在未实现的构建上是绿的(裸余弦下同值对本来就 = 1),
// 所以它不是 born-red 判据,而是抓 M-C4 的那张网。别因为它「一开始就绿」删掉它:
// 实测「只减 query」时 C4a 的 top-1 仍然是 target,C4a 抓不到这个错误。
test('C4b: a block identical to the query always scores a cosine of 1', async (t) => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
// 一条与 query 完全同值的块,外加两条背景块,凑出会触发校正的空间
const twin = store.putBlock({ text: '与查询同值的那条', vector: QUERY_VEC, fingerprint: SPACE })
for (const vec of [HUB_VEC, ...DISTRACTOR_VECS.slice(0, 3)]) {
store.putBlock({ text: `背景块 ${Math.random()}`, vector: vec, fingerprint: SPACE })
}
store.rebuildCentroid(SPACE)
const hits = await recallBlocksHybrid(store, makeEmbedder(), QUERY_TEXT, { topK: 8, minScore: 0 })
const twinHit = hits.find((hit) => hit.id === twin.id)
assert.ok(twinHit !== undefined, `C4b: precondition — the twin block must be scored; got ${hits.length} hits`)
t.diagnostic(`C4b twin vectorHit = ${twinHit.vectorHit}`)
assert.ok(
Math.abs(twinHit.vectorHit - 1) < 1e-9,
'C4b: an identical vector pair must score exactly 1. ' +
'A value near 0.1897 on this 5-block fixture is the signature of centering ONE '
+ 'side only ' +
'(M-C4) — subtracting the mean from the query but not the block mixes ' +
'two coordinate systems and the cosine stops being an angle. ' +
`got ${twinHit.vectorHit}`,
)
} finally {
store.close()
}
})
// ── C4c: 没有中心时照常返回结果,只是不校正 ──────────────────────────────
//
// criteria 里写死了:C3 判 stale 的那一轮「仍返回结果(用未校正向量)」。
// 这条守的是「拿不到中心就让召回失败」这个错误,不是「校正对不对」。
test('C4c: without a centroid the recall still returns results, only uncorrected', async (t) => {
const { store, keyOfId } = seedStore({ withCentroid: false })
try {
assert.equal(store.loadCentroid(SPACE), undefined, 'precondition: no centroid exists in this fixture')
const hits = await recallByKey(store, keyOfId)
t.diagnostic(`C4c uncorrected ranking: ${fmt(hits)}`)
assert.ok(hits.length > 0, 'C4c: a missing centroid must not empty the result set')
assert.equal(
hits[0].key,
'hub',
'C4c: without a centroid the ranking stays uncorrected, so the hub still wins. ' +
'If this is "target", something is centering without a centroid to center on. ' +
`got ${fmt(hits)}`,
)
} finally {
store.close()
}
})
// ── C4d: 生产路径必须自己把中心建出来 ────────────────────────────────────
//
// ★ 这条是 C4 的第一层,也是最容易被漏掉的一半。
// C4a/b/c 都用 `seedStore()` 手工调 `rebuildCentroid`,所以它们只能证明
// 「去均值的算术是对的」,证明不了「生产上中心真的会出现」。
// 而生产上恰恰是后者不存在:`saveSpaceCentroid` / `centroidStatus` /
// `centroidRecallState` 在 lib/ 里都没有生产调用者,`rebuildCentroid`
// 只有测试在调 —— `graph_centroid` 曾经永远是空表。
//
// 所以这条判据走真 `apply()` + 真 `commit()`,不碰 store 内部:
// 写完之后 `loadCentroid` 必须已经能读到中心,且召回必须已经用上校正。
test('C4d: the production commit path builds the centroid it later reads', async () => {
const { Context } = await import('@deepseek-ai/cordis')
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
const { apply: applyMemory } = await import('../lib/index.js')
const { mkdtempSync, rmSync } = await import('node:fs')
const { tmpdir } = await import('node:os')
const { join } = await import('node:path')
const dir = mkdtempSync(join(tmpdir(), 'c4d-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
try {
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), embedder: makeEmbedder() })
// 前置:还没写东西时不该有中心
assert.equal(
mem.store.loadCentroid(SPACE),
undefined,
'precondition: a fresh database has no centroid',
)
// 全部走生产入口,不手工调 rebuildCentroid
const keyOfId = new Map()
for (const block of CORPUS) {
const written = await mem.commit({ text: block.text })
keyOfId.set(written.id, block.key)
assert.equal(written.embedded, true, `precondition: ${block.key} must actually be embedded`)
}
// ★ The first commit builds a centroid from the blocks it has seen SO FAR,
// so right after a burst of writes the centroid still describes only the
// first one — and with MIN_SAMPLE it is not even allowed to claim a direction
// yet. The write path schedules a drift rebuild; wait for it before asserting
// on the centroid's CONTENTS rather than merely on its existence.
//
// Measured without this wait: vectorCount=1, meanLength=1.0000 on the same
// fixture, and a 1-block "mean" is that block itself.
await new Promise((resolve) => setTimeout(resolve, 3000))
const centroid = mem.store.loadCentroid(SPACE)
assert.ok(
centroid !== undefined,
'C4d: after commits through the production path a centroid must exist. ' +
'If this is undefined, nothing in lib/ ever builds one and centering can ' +
'never apply in real use — the arithmetic tests above would still pass ' +
'because they build the centroid by hand.',
)
assert.equal(centroid.isAnomalous, true, 'C4d: and it must be judged anisotropic for this fixture')
assert.equal(centroid.dim, DIM, 'C4d: its width must match the space')
// ★ The existence check above passes even when the centroid describes a
// single block — which is exactly the state right after the first commit.
// A 1-block mean is that block itself (norm 1.0, so it clears the 0.6
// threshold by construction), and centering by it drops that block as a zero
// vector. Pin the coverage, not just the presence.
assert.equal(
centroid.vectorCount,
CORPUS.length,
'C4d: the centroid must describe every block in the space, not only the ones '
+ 'present at the first commit. A smaller count means the drift rebuild '
+ 'never caught up and centering is subtracting the wrong mean.',
)
// 端到端:同一个库,召回必须已经用上校正
const hits = await recallBlocksHybrid(mem.store, makeEmbedder(), QUERY_TEXT, { topK: 8 })
assert.ok(hits.length > 0, 'C4d: the recall must return something')
const top = hits[0]
assert.equal(
keyOfId.get(top.id),
'target',
'C4d: the whole point — production write + production read must together ' +
'produce the corrected ranking. A "hub" winner here means the two halves ' +
'are not connected even though each one works on its own. ' +
`got ${hits.map((h) => keyOfId.get(h.id) ?? h.id).join(',')}`,
)
} finally {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
}
})
// ── C4e: 块数漂移超 20% 后,写入侧必须让中心重新跟上 ──────────────────────
//
// ★ 这条守的是我在 0a6adff 里自己漏掉的那一半。
// 那时的 `#ensureCentroid` 是「没有中心就建一个,有就 return」——
// 于是中心永远停在第一次 commit 的快照上,块数翻多少倍它都不动。
// 这正是这一整轮反复抓的同一个病:**逻辑写了,但没有被调到**。
// `centroidStatus` 里那套 stale 判定(漂移 > 20% + 10 分钟节流 + 异步重建)
// 原本在 lib/ 里零生产调用者,不接上的话它只是文件里的一段死代码。
//
// C4a/b/c 都手工 `rebuildCentroid`,C4d 只验证「首次建」,三条都照不到这里:
// 中心存在且过期,与中心不存在,是两个不同的失效形态。
test('C4e: a drifted centroid is refreshed by the write path, not left stale forever', async (t) => {
// 异步重建靠 setTimeout,用假时钟把 2s 拨过去,免得测试真的睡两秒。
t.mock.timers.enable({ apis: ['setTimeout'] })
const { Context } = await import('@deepseek-ai/cordis')
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
const { apply: applyMemory } = await import('../lib/index.js')
const { mkdtempSync, rmSync } = await import('node:fs')
const { tmpdir } = await import('node:os')
const { join } = await import('node:path')
const dir = mkdtempSync(join(tmpdir(), 'c4e-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
try {
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), embedder: makeEmbedder() })
// 第一条:写入侧顺手建出中心,此时它只见过 1 条块
await mem.commit({ text: CORPUS[0].text })
const born = mem.store.loadCentroid(SPACE)
assert.ok(born !== undefined, 'precondition: the first commit must build a centroid')
const bornCount = born.vectorCount
assert.ok(bornCount >= 1, `precondition: the centroid must have counted blocks, got ${bornCount}`)
// 继续写,直到块数相对建中心时漂移超过 20%
for (const block of CORPUS.slice(1)) await mem.commit({ text: block.text })
// ★ 这里刻意**不**调 centroidStatus。
// 它自己就会调度重建 —— 无论写入侧接没接,只要测试里调了它,
// 重建就会发生,这条判据就变成了自己证明自己。第一次写这条时
// 就是这么写的,M4 变异(退回 0a6adff 的「有中心就 return」)
// 居然 6/6 全绿,才把这个问题缴出来。
const drift = (CORPUS.length - bornCount) / bornCount
assert.ok(
drift > 0.2,
`precondition: this fixture must drift past the 20% ratio; got ${(drift * 100).toFixed(0)}%`,
)
// 假时钟把重建的 2s 拨过去。
// 只有写入侧真的调过 centroidStatus,这里才会有回调可跑。
t.mock.timers.tick(2000)
const healed = mem.store.loadCentroid(SPACE)
assert.ok(healed !== undefined, 'C4e: the centroid must still exist after the scheduled rebuild')
assert.ok(
healed.vectorCount > bornCount,
'C4e: a centroid that drifted past the 20% ratio must be rebuilt by the write path. ' +
`It is still describing ${healed.vectorCount} blocks while ${CORPUS.length} carry vectors — ` +
'that means nothing in lib/ ever consults the stale judgement, so the mean stays frozen ' +
'on whatever the first commit happened to see, and centering slowly stops describing ' +
'the space it is supposed to correct.',
)
assert.equal(
healed.vectorCount,
CORPUS.length,
'C4e: the rebuilt centroid must cover every vectored block in this space',
)
} finally {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
}
})
// ── C4g: 各向同性空间必须完全不校正(isAnomalous 门控)────────────────────
//
// ★ 这条守的是「门控本身」,不是「校正算得对不对」。
// C4a/b/c/d/e 五条用的全是有各向异性的 fixture(C4-0 与 C4d 还专门断言
// isAnomalous === true),C4c 是「没有中心行」—— 三条都照不到「有中心、但
// 中心判定为各向同性」这个分支。实测把 lib/recall.js 里的
// `centroid?.isAnomalous === true &&` 删掉,上述五条加 phase-b-h、phase-b-a
// 全部全绿:门控是裸的。
//
// 数字来源:同一份探针分别打「门控在」与「门控被拆」两个 lib,实测对照,
// 不是纸上推的。各向同性空间(8 条正交 one-hot):
// meanLength = 0.353553(= 1/sqrt(8)),isAnomalous = false
// query = unit(ones) 门控在 → 8 条命中,每条 cosine = 0.3536
// 门控拆 → 0 条命中(去均值后各块余弦转负,被 V5 的
// 结构性门 `vectorScore <= 0 && symbolHit === 0` 全丢)
// 为什么用这条 query:它的方向就是均值方向,去均值后与所有块的夹角都变成钝角,
// 落差是二值化的(8 → 0),不依赖阈值也不依赖浮点容差。
test('C4g: an isotropic space is left uncorrected — the isAnomalous gate', async (t) => {
const { Context } = await import('@deepseek-ai/cordis')
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
const { apply: applyMemory } = await import('../lib/index.js')
const { mkdtempSync, rmSync } = await import('node:fs')
const { tmpdir } = await import('node:os')
const { join } = await import('node:path')
const ISO_DIM = 8
const ISO_SPACE = 'c4g:isotropic:test'
const ones = new Array(ISO_DIM).fill(1)
const queryA = unit(ones) // ≈ 均值方向
const queryB = unit([0.5, 0.3, 0.2, 0, 0, 0, 0, 0]) // 三个方向轴的混合
const texts = Array.from({ length: ISO_DIM }, (_, i) => `正交块 ${i}`)
const vecOf = new Map(texts.map((text, i) => [text, axis(i)]))
vecOf.set('Q-A', queryA)
vecOf.set('Q-B', queryB)
const isoEmbedder = {
async embedText(text) { return vecOf.get(text) ?? new Array(ISO_DIM).fill(0) },
info: () => ({ fingerprint: ISO_SPACE, dimension: ISO_DIM }),
}
const dir = mkdtempSync(join(tmpdir(), 'c4g-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
try {
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), embedder: isoEmbedder })
const keyOf = new Map()
for (const text of texts) {
const written = await mem.commit({ text })
keyOf.set(written.id, text)
}
// 让写入侧的漂移重建跑完,断言的是「生产路径建出的中心」而不是半成品
await new Promise((resolve) => setTimeout(resolve, 3000))
const centroid = mem.store.loadCentroid(mem.space().fingerprint)
assert.ok(centroid !== undefined, 'C4g precondition: the production path must still build a centroid')
assert.equal(centroid.isAnomalous, false, 'C4g precondition: 8 orthogonal axes are isotropic')
assert.ok(
Math.abs((centroid.stats?.meanLength ?? 0) - 1 / Math.sqrt(ISO_DIM)) < 1e-6,
`C4g precondition: meanLength must be 1/sqrt(8); got ${centroid.stats?.meanLength}`,
)
assert.equal(centroid.vectorCount, ISO_DIM, 'C4g precondition: every block contributed')
// ① 方向接近均值的 query:门控在 ⇒ 全部命中,且余弦就是 1/sqrt(8)
const hitsA = await recallBlocksHybrid(mem.store, isoEmbedder, 'Q-A', { topK: ISO_DIM, minScore: 0 })
t.diagnostic(`C4g mean-like query: n=${hitsA.length} ${hitsA.map((h) => `${Number(h.vectorHit ?? 0).toFixed(4)}`).join(' ')}`)
assert.equal(
hitsA.length,
ISO_DIM,
'C4g: an isotropic space must NOT be centred. Removing the isAnomalous gate turns '
+ 'every cosine negative here and V5 drops the lot — the count collapses from 8 to 0.',
)
for (const hit of hitsA) {
assert.ok(
Math.abs(Number(hit.vectorHit) - 1 / Math.sqrt(ISO_DIM)) < 1e-4,
`C4g: each block keeps its raw cosine 1/sqrt(8); got ${hit.vectorHit}`,
)
}
// ② 三轴混合的 query:钉死「完全没做去均值」的具体数值
const hitsB = await recallBlocksHybrid(mem.store, isoEmbedder, 'Q-B', { topK: 3, minScore: 0 })
const byKey = new Map(hitsB.map((h) => [keyOf.get(h.id), Number(h.vectorHit)]))
t.diagnostic(`C4g three-axis query: ${[...byKey].map(([k, v]) => `${k}=${v.toFixed(4)}`).join(' ')}`)
assert.ok(Math.abs((byKey.get('正交块 0') ?? 0) - 0.8111) < 1e-4, 'C4g: block 0 keeps cosine 0.8111')
assert.ok(Math.abs((byKey.get('正交块 1') ?? 0) - 0.4867) < 1e-4, 'C4g: block 1 keeps cosine 0.4867')
assert.ok(Math.abs((byKey.get('正交块 2') ?? 0) - 0.3244) < 1e-4, 'C4g: block 2 keeps cosine 0.3244')
} finally {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
}
})
// ── C3-k: 召回热路径不得出现计数查询 ────────────────────────────────────────
//
// `centroidStatus` 的第一行是 `countVectored`(一条 COUNT),而它每轮 pre-step
// 都会跑 —— C3-k 明令该查询只能出现在写入侧。实测往 lib/recall.js 里塞一行
// `store.countVectored(fingerprint)`,c4 6/6、phase-b-h、phase-b-a 全绿:
// 没有判据在守这条边界。所以这里用静态扫描把它钉住。
//
// ★ 扫描必须自证有效:同一段逻辑对 lib/index.js 跑一遍,必须能找到
// `centroidStatus(` —— 否则正则写错时,上面两条「不包含」会永远为真。
test('C3-k: the recall hot path must not run a COUNT query', async () => {
const { readFileSync } = await import('node:fs')
const strip = (relative) => readFileSync(new URL(relative, import.meta.url), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, ' ')
.split('\n')
.map((line) => line.replace(/\/\/.*$/, ''))
.join('\n')
const recall = strip('../lib/recall.js')
assert.ok(
!/\bcountVectored\s*\(/.test(recall),
'C3-k: lib/recall.js must not call countVectored — that COUNT belongs to the write path',
)
assert.ok(
!/\bcentroidStatus\s*\(/.test(recall),
'C3-k: lib/recall.js must not call centroidStatus — it runs countVectored internally, '
+ 'and the recall path may only read the centroid (loadCentroid)',
)
// 反向自检:同样的扫描必须能在写入侧看见那次调用,证明扫描不是空转
const index = strip('../lib/index.js')
assert.ok(
/\bcentroidStatus\s*\(/.test(index),
'C3-k self-check: the scan must be able to SEE a centroidStatus call — lib/index.js '
+ 'calls it on the write path. If this fails the two assertions above prove nothing.',
)
assert.ok(
/\bloadCentroid\s*\(/.test(recall),
'C3-k: the recall path reads the centroid through loadCentroid — and only that',
)
})
// ── C4h: 单块空间不得被判为各向异性(MIN_SAMPLE)──────────────────────────
//
// 一条向量的「均值」就是它自己,模长恒为 1.0,必然越过 0.6 阈值 —— 于是
// 「只有一条块的空间」会被判成各向异性,然后拿这条块自己去均值。减完是零
// 向量,被 recall.js 的 `scoringVector === null` 跳过,唯一那条块就此从
// 召回里消失。实测(真时钟、真 commit):第 8 次 commit 之前 centroidCount=1、
// meanLength=1.0000,同一库有中心时命中 1 条、无中心时命中 3 条。
//
// 判据落在两处:判定本身(isAnomalous 必须 false),以及后果(唯一那条块
// 必须仍然可召回)。
test('C4h: a single-block space is not anisotropic, and its only block stays recallable', async () => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
const fp = 'c4h:single:test'
const only = axis(0)
const written = store.putBlock({ text: '唯一的一条块', vector: only, fingerprint: fp })
const built = store.rebuildCentroid(fp)
assert.equal(built.vectorCount, 1, 'C4h precondition: exactly one vector contributed')
assert.ok(Math.abs(built.meanLength - 1) < 1e-9, 'C4h precondition: a one-vector mean has norm 1')
assert.equal(
built.isAnomalous,
false,
'C4h: a one-vector mean IS that vector (norm 1.0), so the 0.6 test alone always says '
+ '"anisotropic". MIN_SAMPLE must refuse to call a single sample a direction — otherwise '
+ 'centering subtracts the block from itself and the zero vector is dropped from recall.',
)
const embedder = {
async embedText() { return only },
info: () => ({ fingerprint: fp, dimension: DIM }),
}
const hits = await recallBlocksHybrid(store, embedder, '与那条块方向相同的问题', { topK: 3, minScore: 0 })
assert.equal(
hits.length,
1,
'C4h: the only block in the space must still be recallable. A count of 0 means the '
+ 'single-block "centroid" was applied and its own block was projected onto the zero '
+ 'vector.',
)
assert.equal(hits[0].id, written.id, 'C4h: and it must be the block that was written')
} finally {
store.close()
}
})
// ── C4i: 浮点尘埃不得被当成方向 ─────────────────────────────────────────────
//
// 一个等于空间均值的块,与均值的差只有浮点残差(~1e-17)。如果把它归一化,
// 这堆垃圾会变成一个「单位向量」,再与 query 算出 0.2183 这样的分数 —— 看起来
// 像信号,实际是噪声。实测(2 条完全相同的块,query 与块方向不同):
//
// 原始余弦(无中心) 0.8608
// `norm > 0` 时代的输出 0.2183 ← 尘埃被重新归一化
// 现在的输出 无命中 ← 尘埃被判定为「没有方向」
//
// ★ 这条判据钉的是**不变式**,不是某个具体策略:
// 「任何返回的命中,其 vectorHit 必须是真实余弦(0.8608),绝不能是尘埃
// 推导出来的 0.22 量级」。因此它同时容得下两种未来做法 —— 丢弃这些块
// (当前),或对无方差的空间干脆不校正(若以后加上界保护)。
// 它唯一不允许的,就是继续拿尘埃打分。
test('C4i: floating-point dust is never scored as a direction', async (t) => {
const DUP_DIM = 8
const fp = 'c4i:duplicate:test'
const blockVec = unit([1, 0.2, 0.1, 0, 0, 0, 0, 0])
const queryVec = unit([1, 0.9, 0.1, 0, 0, 0, 0, 0])
const RAW_COSINE = 0.8608 // measured; see the header
const store = new BlockStore({ path: ':memory:' }).open()
try {
for (let i = 0; i < 2; i += 1) {
store.putBlock({ text: `重复的块 ${i}`, vector: blockVec, fingerprint: fp })
}
const built = store.rebuildCentroid(fp)
assert.ok(built.meanLength > 0.999, `C4i precondition: identical blocks have a unit mean; got ${built.meanLength}`)
assert.equal(built.isAnomalous, true, 'C4i precondition: the fixture must actually enable centring')
const embedder = {
async embedText() { return queryVec },
info: () => ({ fingerprint: fp, dimension: DUP_DIM }),
}
const hits = await recallBlocksHybrid(store, embedder, 'q', { topK: 10, minScore: 0 })
t.diagnostic(`C4i duplicate-space hits=${hits.length} ${hits.map((h) => Number(h.vectorHit).toFixed(4)).join(',')}`)
for (const hit of hits) {
assert.ok(
Math.abs(Number(hit.vectorHit) - RAW_COSINE) < 1e-3,
'C4i: every hit must carry the RAW cosine 0.8608. A value near 0.22 is '
+ 'renormalised floating-point dust: the block differs from the space mean '
+ 'only by ~1e-17, and turning that into a unit vector manufactures a '
+ 'direction that does not exist. Treat it as "no direction" instead.',
)
}
} finally {
store.close()
}
})
// ── C4j: stale 时退回未校正(父片令原文)──────────────────────────────────
//
// 片令写得很清楚:「判 stale 的那一轮仍返回结果(用未校正向量)」。
// 召回侧不能自己判 stale —— `centroidStatus` 第一行是 countVectored,C3-k 明令
// 它不得进热路径。所以判定在写入侧做,结果落在 centroid 行的 `stats.stale` 上,
// 召回侧只读这个标记。
//
// ★ 这条判据是在修掉一个真 bug 之后补的:最初把标记判断写成
// `centroid.stats?.stale !== true`,而标记存的是 JSON 数字 1 —— JS 里
// `1 !== true` 恒为真,于是整个 guard 变成 no-op。**当时全套 305 条判据全绿。**
// 换成 `!centroid.stats?.stale` 才生效。
// 没有这条判据,"写了但永不生效"会一直躺在那儿。
//
// fixture 用 C4 那个 hub/target 空间:那里的差别是可观察的 ——
// 校正时 target 胜(0.9000),不校正时 hub 胜(0.9928,即裸余弦)。
test('C4j: a centroid marked stale falls back to uncorrected vectors', async (t) => {
const DIM = 8
const unit = (v) => { const n = Math.hypot(...v); return n === 0 ? v : v.map((x) => x / n) }
const axis = (i) => unit(Array.from({ length: DIM }, (_, k) => (k === i ? 1 : 0)))
const BACKBONE = unit([1.0, 0.55, 0.42, 0.30, 0.22, 0.16, 0.11, 0.07])
const mk = (eps, ax) => unit(BACKBONE.map((x, i) => x + eps * ax[i]))
const HUB = { key: 'hub', text: '这段时间一切照旧。', vec: mk(0.02, axis(1)) }
const TARGET = { key: 'target', text: '第七号仓库的备用门禁码是 QP47-812。', vec: mk(0.30, axis(7)) }
const DISTRACTORS = [0, 2, 3, 4, 5, 6].map((i) => ({ key: `d${i}`, text: `干扰块 ${i}。`, vec: mk(0.30, axis(i)) }))
const CORPUS = [HUB, TARGET, ...DISTRACTORS]
const QUERY_TEXT = '上次说的那个四位数是什么来着?'
const QUERY_VEC = unit(BACKBONE.map((x, i) => x + 0.30 * 0.40 * axis(7)[i]))
const byText = new Map(CORPUS.map((b) => [b.text, b.vec]))
const store = new BlockStore({ path: ':memory:' }).open()
try {
const fp = 'c4j:stale-fallback'
const keyOf = new Map()
for (const b of CORPUS) keyOf.set(store.putBlock({ text: b.text, vector: b.vec, fingerprint: fp }).id, b.key)
const built = store.rebuildCentroid(fp)
assert.equal(built.isAnomalous, true, 'C4j precondition: the fixture must enable centring')
const embedder = {
async embedText(text) { return text === QUERY_TEXT ? QUERY_VEC : (byText.get(text) ?? new Array(DIM).fill(0)) },
info: () => ({ fingerprint: fp, dimension: DIM }),
}
const top1 = async () => {
const hits = await recallBlocksHybrid(store, embedder, QUERY_TEXT, { topK: 3, minScore: 0 })
t.diagnostic(`C4j ranking: ${hits.map((h) => `${keyOf.get(h.id)}=${Number(h.vectorHit ?? 0).toFixed(4)}`).join(' ')}`)
return { key: keyOf.get(hits[0]?.id), score: Number(hits[0]?.vectorHit ?? 0) }
}
const fresh = await top1()
assert.equal(fresh.key, 'target', 'C4j: with a usable centroid the target wins')
// The write path's verdict, persisted on the row.
assert.equal(store.markCentroidStale(fp), true, 'C4j: the flag must actually be written')
const stale = await top1()
assert.equal(
stale.key,
'hub',
'C4j: a round that judges the centroid stale must fall back to UNCORRECTED vectors '
+ '(the raw cosine then puts the hub first). If this still says "target", the flag '
+ 'is being ignored — the exact bug that a strict `!== true` comparison produced '
+ 'while the whole suite stayed green.',
)
assert.ok(Math.abs(stale.score - 0.9928) < 1e-3, `C4j: and it must be the raw cosine; got ${stale.score}`)
store.rebuildCentroid(fp)
const healed = await top1()
assert.equal(healed.key, 'target', 'C4j: a rebuild clears the flag and centring returns')
} finally {
store.close()
}
})
// ── C4k: 落标记这件事必须是写入侧自己干的,不是测试替它干 ───────────────────
//
// C4j 里有一行 `store.markCentroidStale(fp)` —— 那是判据**手工**打的标记,它只
// 证明「召回侧会尊重标记」,不证明「生产路径会打这个标记」。
//
// 实测:把 `lib/index.js` 里那行 `if (status?.stale === true) this.store
// .markCentroidStale(...)` 整个删掉,**全套 306 条判据全绿**。也就是说这条接线
// 此前没有任何判据守着 —— 与 W8(saveSpaceCentroid 零生产调用点)、C4d(中心
// 靠测试手工 rebuildCentroid)是同一个病:手工喂进去的输入,证明不了生产路径。
//
// 这条判据只用生产入口:`apply()` + `commit()`,自己不打任何标记。
test('C4k: the write path itself marks a drifted centroid stale', async () => {
const { Context } = await import('@deepseek-ai/cordis')
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
const { apply: applyMemory } = await import('../lib/index.js')
const { mkdtempSync, rmSync } = await import('node:fs')
const { tmpdir } = await import('node:os')
const { join } = await import('node:path')
const DIM = 4
const dir = mkdtempSync(join(tmpdir(), 'c4k-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
try {
const mem = applyMemory(ctx, {
path: join(dir, 'm.db'),
embedder: {
async embedText() { return unit([1, 0.1, 0, 0]) },
info: () => ({ fingerprint: 'c4k:write-marks', dimension: DIM }),
},
})
const fp = mem.space().fingerprint
// Build a centroid, then let the scheduled rebuild land so the count is settled.
for (let i = 0; i < 8; i += 1) await mem.commit({ text: `块 ${i}` })
await new Promise((resolve) => setTimeout(resolve, 3000))
const settled = mem.store.loadCentroid(fp)
assert.ok(settled !== undefined, 'C4k precondition: a centroid must exist')
assert.equal(
settled.stats?.stale,
undefined,
'C4k precondition: a freshly rebuilt centroid carries no stale flag',
)
// Drift it past the 20% ratio through the production write path only.
for (let i = 8; i < 11; i += 1) await mem.commit({ text: `块 ${i}` })
const drifted = mem.store.loadCentroid(fp)
assert.equal(
drifted.stats?.stale,
1,
'C4k: the WRITE path must persist the stale verdict — the recall side is not '
+ 'allowed to judge it (C3-k), so if this flag is missing, recall keeps '
+ 'centring with a mean that no longer describes the space. Deleting that one '
+ 'line in #ensureCentroid left all 306 criteria green, which is why this '
+ 'criterion exists.',
)
} finally {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
}
})

View File

@ -0,0 +1,390 @@
/**
* C5 · 「为什么不校正」必须可区分。
*
* ## 这条判据要解决的病
*
* C3 的 `centroidStatus` 早就把四种失效原因都算出来了,但对外只给
* `available: false` 一个结论。于是:
*
* · `no_centroid` 与 `dim_mismatch` 都是 `stale: null`
* · `isotropic` 与健康空间都 `stale: false`,只差 available
*
* 对「能不能校正」这个问题够用;对「为什么语义召回一直不校正」完全不够。
* 运维看到的是一句 `available: false`,四种完全不同的处置方式(建中心 /
* 换维度 / 等重建 / 这个空间本来就没有公共方向)被压成同一个症状。
*
* ## 判据形态
*
* 每一种原因一个 fixture,直接断 `reason` 这个值本身 —— 不断 available、
* 不断 stale,因为它们本来就是多义的。同时断「available 与 reason 是否自洽」,
* 防止有人把 reason 写成与结论脱节的装饰。
*
* ★ 所有调用都注入 `schedule: () => {}`:`centroidStatus` 判 stale 时会调度
* 重建,那是副作用。C4e 第一版就因为没隔离它而自证过 —— 判据必须只看事实,
* 不触发被测行为。
*
* @module dsh-memory/test/phase-b-c5-reasons
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { BlockStore } from '../lib/store.js'
const DIM = 4
const unit = (v) => { const n = Math.hypot(...v); return n === 0 ? v : v.map((x) => x / n) }
const noSchedule = () => {}
/** 每条判据都用它:一次调用,注入空调度,避免副作用串味。 */
const statusOf = (store, fp, queryDim = DIM) =>
store.centroidStatus(fp, { queryDim, schedule: noSchedule })
test('C5: a space with blocks but no centroid row reports no_centroid', () => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
const fp = 'c5:no-centroid'
for (let i = 0; i < 3; i += 1) store.putBlock({ text: `a${i}`, vector: unit([1, i * 0.01, 0, 0]), fingerprint: fp })
const s = statusOf(store, fp)
assert.equal(s.reason, 'no_centroid', 'a missing row is a missing row, not a dimension problem')
assert.equal(s.available, false)
assert.equal(s.dim, null, 'and it must not invent a dimension')
assert.equal(s.vectorCount, null)
} finally {
store.close()
}
})
test('C5: a centroid of the wrong width reports dim_mismatch', () => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
const fp = 'c5:dim-mismatch'
for (let i = 0; i < 3; i += 1) store.putBlock({ text: `b${i}`, vector: unit([1, 0.01 * i, 0, 0]), fingerprint: fp })
store.rebuildCentroid(fp)
const s = statusOf(store, fp, 8)
assert.equal(s.reason, 'dim_mismatch', 'a centroid exists and is readable — it is simply the wrong width')
assert.equal(s.available, false)
assert.equal(s.dim, DIM, 'the stored width must still be reported, so the operator can see the mismatch')
assert.notEqual(s.dim, 8)
} finally {
store.close()
}
})
test('C5: a centroid row with zero samples reports zero_sample', () => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
const fp = 'c5:zero-sample'
// A row written by an older build / a migration: present, but built from nothing.
store.saveCentroid({
fingerprint: fp, dim: DIM, vectorCount: 0,
mean: new Array(DIM).fill(0), isAnomalous: false,
stats: { source: 'migration' },
})
const s = statusOf(store, fp)
assert.equal(s.reason, 'zero_sample', 'a centroid built from zero vectors is its own cause')
assert.equal(s.available, false)
assert.equal(s.vectorCount, 0)
} finally {
store.close()
}
})
test('C5: a centroid that drifted past the ratio reports stale', () => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
const fp = 'c5:stale'
for (let i = 0; i < 8; i += 1) store.putBlock({ text: `c${i}`, vector: unit([1, 0.001 * i, 0, 0]), fingerprint: fp })
store.rebuildCentroid(fp)
for (let i = 8; i < 12; i += 1) store.putBlock({ text: `c${i}`, vector: unit([1, 0.001 * i, 0, 0]), fingerprint: fp })
const s = statusOf(store, fp)
assert.equal(s.reason, 'stale', 'the centroid is usable in principle, it just no longer describes the space')
assert.equal(s.available, false)
assert.equal(s.stale, true)
assert.ok(s.delta > 0.2, `precondition: the drift must exceed the ratio; got ${s.delta}`)
} finally {
store.close()
}
})
test('C5: an isotropic space reports isotropic, not stale', () => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
const fp = 'c5:isotropic'
for (const [i, v] of [[1, 0, 0, 0], [0, 1, 0, 0], [-1, 0, 0, 0], [0, -1, 0, 0]].entries()) {
store.putBlock({ text: `d${i}`, vector: v, fingerprint: fp })
}
store.rebuildCentroid(fp)
const s = statusOf(store, fp)
assert.equal(s.reason, 'isotropic', 'four orthogonal axes have no common direction to remove — '
+ 'this is not a staleness problem and must not be reported as one')
assert.equal(s.available, false)
assert.equal(s.stale, false, 'isotropic is a permanent property of the space, not a transient state')
} finally {
store.close()
}
})
test('C5: a healthy space reports ok, and only ok carries available=true', () => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
const fp = 'c5:healthy'
for (let i = 0; i < 8; i += 1) store.putBlock({ text: `e${i}`, vector: unit([1, 0.001 * i, 0, 0]), fingerprint: fp })
store.rebuildCentroid(fp)
const s = statusOf(store, fp)
assert.equal(s.reason, 'ok')
assert.equal(s.available, true)
assert.equal(s.stale, false)
} finally {
store.close()
}
})
test('C5: reason and available never disagree', () => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
// Build every branch at once and check the invariant holds in all of them.
const cases = []
const mk = (fp, n, opts = {}) => {
for (let i = 0; i < n; i += 1) {
store.putBlock({ text: `${fp}-${i}`, vector: unit([1, 0.001 * i, 0, 0]), fingerprint: fp })
}
if (opts.build !== false) store.rebuildCentroid(fp)
}
mk('c5:i1', 3, { build: false }) // no_centroid
mk('c5:i2', 3) // ok / dim_mismatch via queryDim
cases.push([statusOf(store, 'c5:i1'), 'no_centroid'])
cases.push([statusOf(store, 'c5:i2'), 'ok'])
cases.push([statusOf(store, 'c5:i2', 8), 'dim_mismatch'])
for (const [s, expected] of cases) {
assert.equal(s.reason, expected)
assert.equal(
s.available,
s.reason === 'ok',
`C5: available must be true for exactly one reason (ok); got reason=${s.reason} available=${s.available}`,
)
}
} finally {
store.close()
}
})
test('C5: centroidRecallState keeps its stale:null projection but carries the reason', () => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
const fp = 'c5:recall-projection'
for (let i = 0; i < 8; i += 1) store.putBlock({ text: `f${i}`, vector: unit([1, 0.001 * i, 0, 0]), fingerprint: fp })
store.rebuildCentroid(fp)
for (let i = 8; i < 12; i += 1) store.putBlock({ text: `f${i}`, vector: unit([1, 0.001 * i, 0, 0]), fingerprint: fp })
const projection = store.centroidRecallState(fp, { queryDim: DIM, schedule: noSchedule })
assert.equal(projection.stale, null, 'the recall-facing projection keeps hiding staleness from the scorer')
assert.equal(projection.reason, 'stale', 'but the reason still travels, so an operator can see why')
assert.equal(projection.available, false)
} finally {
store.close()
}
})
// ── C5 的出口:reason 必须能被运维读到,而且读的时候不许有副作用 ─────────────
//
// 上面八条都在 store 层证明「六种原因可区分」。但区分出来没有出口,等于只做完
// 一半:`centroid` 这一项给的是 `loadCentroid` 的原始行,它说得出「中心是什么」,
// 说不出「为什么没在校正」。全仓曾经没有任何地方读 `centroidStatus` 的返回值。
//
// 现在它从 `stats().centroid_state` 出去。两条要求:
// 1. reason 与 store 层判定一致;
// 2. **纯读** —— `centroidStatus` 判 stale 时会登记一次重建,而 stats 是只读
// 接口,不许因此产生写副作用。用空调度器把它按住,`rebuildScheduled`
// 在这里恒为 false,这正是纯读该有的样子。
test('C5: stats() exposes the reason without scheduling a rebuild', async () => {
const { Context } = await import('@deepseek-ai/cordis')
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
const { apply: applyMemory } = await import('../lib/index.js')
const { mkdtempSync, rmSync } = await import('node:fs')
const { tmpdir } = await import('node:os')
const { join } = await import('node:path')
const unit = (v) => { const n = Math.hypot(...v); return n === 0 ? v : v.map((x) => x / n) }
const dir = mkdtempSync(join(tmpdir(), 'c5-stats-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
try {
// ── fresh space: no centroid yet ──────────────────────────────────────
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), embedder: {
async embedText() { return unit([1, 0.1, 0, 0]) },
info: () => ({ fingerprint: 'c5:stats:test', dimension: 4 }),
} })
assert.equal(
mem.stats().centroid_state.reason,
'no_centroid',
'C5: stats() must say WHY centring is off, not merely that no row exists',
)
// ── after writes: healthy ─────────────────────────────────────────────
for (let i = 0; i < 8; i += 1) await mem.commit({ text: `块 ${i}` })
await new Promise((resolve) => setTimeout(resolve, 3000)) // let the drift rebuild land
let s = mem.stats()
assert.equal(s.centroid_state.reason, 'ok', 'a built, current centroid reports ok')
assert.equal(s.centroid_state.available, true)
// Both surfaces coexist: `centroid` is the raw row, `centroid_state` is the
// verdict. Asserting they are the SAME object would be a tautology; the
// meaningful claim is that the raw row still carries what it always did
// (dim / mean) while the verdict carries the reason.
assert.equal(typeof s.centroid?.dim, 'number', 'the raw centroid row is still reported')
assert.ok(Array.isArray(s.centroid_state === null ? null : s.centroid?.mean), 'and its mean is intact')
assert.equal(typeof s.centroid_state.reason, 'string', 'the verdict names a reason')
// ── drift past 20%: stale, and stats() must NOT register a rebuild ────
for (let i = 8; i < 16; i += 1) await mem.commit({ text: `块 ${i}` })
s = mem.stats()
assert.equal(s.centroid_state.reason, 'stale', 'the drift is what the operator needs to see')
assert.equal(s.centroid_state.stale, true)
assert.equal(
s.centroid_state.rebuildScheduled,
false,
'C5: stats() is a READ. If it schedules a rebuild it has become a write path, '
+ 'and every operator polling stats() would keep the rebuild loop alive.',
)
assert.ok(s.centroid_state.delta > 0.2, `precondition: the drift must exceed the ratio; got ${s.centroid_state.delta}`)
} finally {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
}
})
// ── C5 的最后一段:reason 必须出现在**模型真正调用的那个工具**的返回里 ────────
//
// 前九条分别证明:六种原因可区分(store 层)、stats() 带 reason 且纯读
// (plugin 层)。但 dsh 里的模型不会去读 `mem.stats()` —— 它调工具。
//
// 这个缺口在本轮出现过三次同款:P1 的 queryVector 算完丢掉、D2 的计数器算完
// 不打印、C5 的 reason 算完没人读。每一次都是「值存在、判据绿、运维看不见」。
// 工具面是第四个可能断的环节,而且是最靠外的一个:handler 改成挑字段返回,
// 前面所有判据都会继续绿。
//
// 所以这条直接走 `ctx.tools.execute` —— 与 phase-b-h 里调工具的方式一致,
// signal 必须是真实 AbortSignal(ToolRuntime 内部会读 callerSignal.aborted)。
test('C5: the stats TOOL returns the reason, not just the service method', async () => {
const { Context } = await import('@deepseek-ai/cordis')
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
const { apply: applyMemory } = await import('../lib/index.js')
const { mkdtempSync, rmSync } = await import('node:fs')
const { tmpdir } = await import('node:os')
const { join } = await import('node:path')
const callStats = async (ctx) => {
const result = await ctx.tools.execute({
callId: 'c5-stats',
name: 'stablemem_stats',
arguments: {},
signal: new AbortController().signal,
})
const text = result?.content?.[0]?.text ?? result?.value ?? result
return typeof text === 'string' ? JSON.parse(text) : text
}
const dir = mkdtempSync(join(tmpdir(), 'c5-tool-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx)
const mem = applyMemory(ctx, {
path: join(dir, 'm.db'),
embedder: {
async embedText() { return unit([1, 0.1, 0, 0]) },
info: () => ({ fingerprint: 'c5:tool:test', dimension: 4 }),
},
})
try {
const empty = await callStats(ctx)
assert.ok(
'centroid_state' in empty,
'C5: the tool must expose centroid_state — that is the surface the model and '
+ 'the operator actually call. Every other criterion can stay green while a '
+ 'handler that cherry-picks fields drops it.',
)
assert.equal(empty.centroid_state.reason, 'no_centroid')
for (let i = 0; i < 8; i += 1) await mem.commit({ text: `块 ${i}` })
await new Promise((resolve) => setTimeout(resolve, 3000))
const healthy = await callStats(ctx)
assert.equal(healthy.centroid_state.reason, 'ok', 'and the healthy verdict travels too')
assert.equal(healthy.centroid_state.available, true)
assert.ok('last_recall' in healthy, 'precondition: sibling fields still present')
for (let i = 8; i < 16; i += 1) await mem.commit({ text: `块 ${i}` })
const drifted = await callStats(ctx)
assert.equal(drifted.centroid_state.reason, 'stale')
assert.equal(
drifted.centroid_state.rebuildScheduled,
false,
'C5: calling the tool must not schedule a rebuild — a read-only surface that '
+ 'mutates would let a polling operator drive the rebuild loop.',
)
} finally {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
}
})
// ── C5: 样本不足必须与「本来就没方向」分开 ──────────────────────────────────
//
// MIN_SAMPLE 让「一条块」的空间不再判为各向异性(一条块不构成方向),于是它
// 落进了 `isotropic` 分支 —— 而这两件事的修法是相反的:
//
// isotropic 这个空间永远不会有公共方向,别指望校正
// too_few_samples 再写几条块就好了
//
// 报错哪一种不是措辞问题:C5 存在的全部理由,就是让运维知道该往哪儿走。
//
// ★ 这个洞是我自己捅的:MIN_SAMPLE 是我加的,`too_few_samples` 是补上之后才
// 成立的,所以这条判据同时钉住两件事 —— 1 条块的空间报 too_few_samples,
// 以及 2 条块的空间(够 MIN_SAMPLE)不会误报成样本不足。
test('C5: a one-vector space reports too_few_samples, not isotropic', () => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
// ── one vector: undersampled ─────────────────────────────────────────
const fp1 = 'c5:one-vector'
store.putBlock({ text: '唯一一条', vector: unit([1, 0, 0, 0]), fingerprint: fp1 })
const built = store.rebuildCentroid(fp1)
assert.equal(built.vectorCount, 1)
assert.equal(
built.isAnomalous,
false,
'precondition: MIN_SAMPLE keeps one sample from being called a direction',
)
const s1 = statusOf(store, fp1)
assert.equal(
s1.reason,
'too_few_samples',
'C5: "not enough blocks yet" and "this space has no common direction" lead to '
+ 'opposite actions. Reporting a one-vector space as isotropic tells the operator '
+ 'to give up instead of to write more.',
)
assert.equal(s1.available, false)
// ── two vectors: enough to be judged, and NOT reported as undersampled ─
const fp2 = 'c5:two-vectors'
store.putBlock({ text: 'a', vector: unit([1, 0, 0, 0]), fingerprint: fp2 })
store.putBlock({ text: 'b', vector: unit([1, 0.001, 0, 0]), fingerprint: fp2 })
store.rebuildCentroid(fp2)
const s2 = statusOf(store, fp2)
assert.notEqual(
s2.reason,
'too_few_samples',
'precondition: MIN_SAMPLE is 2, so two samples must clear the undersampled branch',
)
} finally {
store.close()
}
})

View File

@ -0,0 +1,265 @@
/**
* Phase B · group G — static (source-text) guards.
*
* ★ This file deliberately imports **nothing** from `lib/`.
*
* G2b and G5 inspect source text. If they lived beside the behavioural guards,
* they would be imported through `lib/index.js` and a broken build would crash
* the whole file — producing "load failed" instead of the actual finding
* ("a disallowed dependency was added"). A guard that cannot report its own
* verdict is not a guard. Keeping this file dependency-free means it survives a
* broken `lib/` and names the offender.
*
* @module dsh-memory/test/phase-b-g-static
*/
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import test from 'node:test'
const LIB = new URL('../lib/', import.meta.url).pathname
/** Every .js file under lib/, recursively. */
function libSources() {
const out = []
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name)
if (entry.isDirectory()) walk(full)
else if (entry.name.endsWith('.js')) out.push(full)
}
}
walk(LIB)
return out
}
const rel = (file) => file.replace(LIB, 'lib/')
test('G2b: lib/ imports only node: builtins and cordis/dsh peers', () => {
// A new runtime dependency (an ONNX binding, an HTTP client, a vector DB) shows
// up here before it shows up in a deployment.
// `dsh-agent-loop` joined this list with lib/loop.js: the loop subclass has to
// extend the harness's own AgentLoop, and that package is declared as a peer in
// package.json exactly like dsh-tools. It is still a first-party harness
// package, not the kind of new runtime dependency (a binding, an HTTP client,
// a vector DB) this guard exists to catch.
const allowed = /^(node:|@deepseek-ai\/(cordis|dsh-llm|dsh-system-prompt|dsh-tools|dsh-session|dsh-scope|dsh-agent-loop)|\.\/|\.\.\/)/
const offenders = []
for (const file of libSources()) {
const text = readFileSync(file, 'utf8')
for (const match of text.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)) {
const spec = match[1]
if (!allowed.test(spec)) offenders.push(`${file.replace(LIB, 'lib/')}: ${spec}`)
}
for (const match of text.matchAll(/^\s*(?:const|let)\s+\w+\s*=\s*require\(\s*['"]([^'"]+)['"]/gm)) {
const spec = match[1]
if (!allowed.test(spec)) offenders.push(`${file.replace(LIB, 'lib/')}: require(${spec})`)
}
}
assert.deepEqual(offenders, [], `disallowed runtime imports:\n${offenders.join('\n')}`)
})
// ── G5: provider identifiers confined to one file ────────────────────────
test('G5: provider-specific identifiers may appear in exactly one lib file', () => {
// The criterion is not "never mention http" — the transport has to live
// somewhere. It is "nowhere EXCEPT the adapter", so swapping the transport
// cannot require edits scattered across recall/scoring/storage.
// The only place allowed to know a transport. Kept as the PREFIX actually
// compared against `rel` — an earlier version compared against
// `ADAPTER.slice(4)` (== 'embed/'), which never matched 'lib/embed/...', so the
// allowlist silently did nothing. It stayed green only because lib/embed/ did
// not exist yet; the moment a legitimate adapter landed it would have flagged
// that adapter's own `fetch`. See G5†.
const ADAPTER = 'lib/embed/'
// Only CODE counts. Comments legitimately say "no endpoint" and "edge endpoint",
// and a scan that cannot tell prose from code produces false positives that
// get the whole criterion disabled. Strip comments, then scan.
const stripComments = (text) =>
text
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
.replace(/(^|[^:])\/\/.*$/gm, '$1') // line comments (not http://)
const forbidden = [
/\bfetch\s*\(/, // outbound call
/Authorization/i, // auth header
/\bBearer\b/,
/https?:\/\//, // literal URL
/\bmodel_dir\b/, // ONNX provider option
/\bapi_key\b/i, // provider credential
]
const offenders = []
for (const file of libSources()) {
const rel = file.replace(LIB, 'lib/')
if (rel.startsWith(ADAPTER)) continue // allowed area
const text = stripComments(readFileSync(file, 'utf8'))
for (const pattern of forbidden) {
const hit = text.match(pattern)
if (hit !== null) offenders.push(`${rel}: ${hit[0]}`)
}
}
assert.deepEqual(offenders, [], `transport leaked outside ${ADAPTER}:\n${offenders.join('\n')}`)
})
// ── mutations ─────────────────────────────────────────────────────────────
//
// These do not mutate lib/ on disk (a test must not edit the tree it is
// verifying). Instead each one re-runs the SAME detection logic over a synthetic
// file list, so the assertion is "the rule as written would have caught this".
// G5† is special: it re-implements the buggy prefix and proves the buggy version
// passes a legitimate adapter, which is exactly how the real bug stayed hidden.
/** The detection core, parameterised by the file list and the allowlist test. */
function detect(files, isAllowed) {
const forbidden = [/\bfetch\s*\(/, /Authorization/i, /\bBearer\b/, /https?:\/\//, /\bmodel_dir\b/, /\bapi_key\b/i]
const stripComments = (text) =>
text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
const offenders = []
for (const { rel, text } of files) {
if (isAllowed(rel)) continue
const code = stripComments(text)
for (const pattern of forbidden) {
const hit = code.match(pattern)
if (hit !== null) offenders.push(`${rel}: ${hit[0]}`)
}
}
return offenders
}
const LEGIT_ADAPTER = {
rel: 'lib/embed/http.js',
text: `export async function embedText(t) {
const r = await fetch('https://example.invalid/embed', {
headers: { Authorization: 'Bearer k' },
})
return (await r.json()).embedding
}`,
}
const VIOLATING_STORE = {
rel: 'lib/store.js',
text: `const r = await fetch('https://x/')`,
}
test('G5†: the OLD prefix (ADAPTER.slice(4)) fails a legitimate adapter — the real bug', () => {
const buggy = (rel) => rel.startsWith('lib/embed/'.slice(4)) // == 'embed/' → never true
const offenders = detect([LEGIT_ADAPTER], buggy)
// The buggy rule flags the adapter's own fetch: a guard that cries wolf.
assert.equal(
offenders.length > 0,
true,
'sanity: the buggy prefix must FAIL to exempt the adapter (that was the bug)',
)
// And that is why it had to be fixed: the correct rule exempts it.
const fixed = (rel) => rel.startsWith('lib/embed/')
assert.deepEqual(detect([LEGIT_ADAPTER], fixed), [], 'the fixed rule exempts the adapter')
})
test('G5b†: a fetch planted in lib/store.js must be reported, naming store.js', () => {
const fixed = (rel) => rel.startsWith('lib/embed/')
const offenders = detect([LEGIT_ADAPTER, VIOLATING_STORE], fixed)
// One line can trip several patterns (`fetch(` AND `https://`), so assert on
// WHICH FILE is named, not on the number of matches.
assert.ok(offenders.length >= 1, `expected at least one offender, got ${JSON.stringify(offenders)}`)
assert.ok(
offenders.every((o) => o.startsWith('lib/store.js: ')),
`every offender must name store.js; got ${JSON.stringify(offenders)}`,
)
assert.ok(
!offenders.some((o) => o.startsWith('lib/embed/')),
'the legitimate adapter must NOT be reported',
)
})
test('G2b†: a disallowed runtime import must be reported with file and package', () => {
const allowed = /^(node:|@deepseek-ai\/(cordis|dsh-llm|dsh-system-prompt|dsh-tools|dsh-session|dsh-scope)|\.\/|\.\.\/)/
const scanImports = (files) => {
const out = []
for (const { rel, text } of files) {
for (const m of text.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)) {
if (!allowed.test(m[1])) out.push(`${rel}: ${m[1]}`)
}
}
return out
}
const offenders = scanImports([
{ rel: 'lib/store.js', text: "import * as ort from 'onnxruntime-node'" },
{ rel: 'lib/recall.js', text: "import { createHash } from 'node:crypto'" },
])
assert.deepEqual(offenders, ['lib/store.js: onnxruntime-node'])
})
// ── the checkpoint runner must not be able to pass with zero checkpoints ──
//
// Replacing the old README-count guard silently removed its second assertion:
// "scripts/checkpoints.js declares at least one checkpoint". Nothing guards that
// any more, and the failure is invisible — stubbing the helper yields
// `0/0 checkpoints passed`, exit code 0, and every test still green.
//
// Two layers, because they catch different causes:
// · the runner itself exits non-zero when `results.length === 0` (rename,
// stub, conditional skip); and
// · this assertion proves the runner still contains that guard, because an
// early `process.exit(0)` bypasses any in-script check entirely.
test('scripts/checkpoints.js registers checkpoints AND refuses to pass with zero', () => {
const root = new URL('..', import.meta.url).pathname
const script = readFileSync(join(root, 'scripts', 'checkpoints.js'), 'utf8')
// 1. It actually registers something. Guards against a rename of the helper.
const declared = [...script.matchAll(/await checkpoint\('/g)].length
assert.ok(
declared > 0,
'scripts/checkpoints.js declares no `await checkpoint(...)` calls — did the helper get renamed?',
)
// 2. It refuses to report success when nothing ran. This is the guard that a
// stub or a conditional skip trips; an early `process.exit(0)` can only be
// caught here, by asserting the guard exists.
assert.match(
script,
/if \(results\.length === 0\)/,
'the runner must contain an explicit zero-results guard',
)
assert.match(
script,
/results\.length === 0[\s\S]{0,200}process\.exitCode = 1/,
'the zero-results guard must set a non-zero exit code',
)
})
// ── README: no unguarded counts ───────────────────────────────────────────
//
// This used to assert that README's test/checkpoint counts matched reality. That
// made the README a second source of truth which every test addition had to
// update — and it drifted five times. The decision was to remove the counts
// instead of guarding them: a number nobody verifies reads as precise while being
// wrong, and the guard cost more than the number was worth.
//
// So the invariant is now the opposite: the README must NOT state a count. If
// someone re-adds one, this fails and the reason is right here.
//
// Scope: this matches DIGIT forms ("119 tests", "10 checkpoints", "10/10 …").
// It does not try to catch a count spelled out in words ("four files"), because
// the README legitimately needs prose like "four files report ERR_MODULE_NOT_FOUND
// — that is the missing-setup symptom" and a prose-matching rule would either
// fire on that or grow into a mini parser. The rule targets the shape a
// maintainer actually writes when pasting a run result, which is digits.
test('README states no test or checkpoint counts', () => {
const readme = readFileSync(join(new URL('..', import.meta.url).pathname, 'README.md'), 'utf8')
const offenders = []
const patterns = [
[/\b\d+\s+tests\b/i, 'a test count'],
[/\b\d+\s+checkpoints\b/i, 'a checkpoint count'],
[/\b\d+\s*\/\s*\d+\s+checkpoints passed/i, 'a pasted pass-count'],
]
for (const [pattern, label] of patterns) {
const hit = readme.match(pattern)
if (hit !== null) offenders.push(`${label}: "${hit[0]}"`)
}
assert.deepEqual(
offenders,
[],
`README must not state counts — they drift and nobody guards them. Remove, don't update:\n${offenders.join('\n')}`,
)
})

232
test/phase-b-g.test.js Normal file
View File

@ -0,0 +1,232 @@
/**
* Phase B · group G — guards.
*
* These exist so that adding vectors cannot quietly damage what phase A shipped,
* and so that the vector *transport* cannot leak into the layers that must stay
* provider-agnostic.
*
* G1 phase A's whole surface still passes (unit + checkpoints) — the golden
* fixture and the checkpoint script are the enforcement, this file only
* pins the invariant that they are wired up.
* G2 no embedder ⇒ no outbound HTTP, no extra module loaded.
* G3 `stablemem_commit` still writes with a dead/absent embedder.
* G4 media stays out of scope (one modality, `text`).
* G5 provider-specific identifiers may exist in exactly one file.
*
* @module dsh-memory/test/phase-b-g
*/
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import test from 'node:test'
import { Context } from '@deepseek-ai/cordis'
import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
import { ToolRuntime } from '@deepseek-ai/dsh-tools'
import { apply as applyMemory } from '../lib/index.js'
import { BlockStore, MODALITY_TEXT } from '../lib/store.js'
const LIB = new URL('../lib/', import.meta.url).pathname
/** Every .js file under lib/, recursively. */
function libSources() {
const out = []
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name)
if (entry.isDirectory()) walk(full)
else if (entry.name.endsWith('.js')) out.push(full)
}
}
walk(LIB)
return out
}
/** A real cordis app carrying the services the plugin injects. */
function makeApp() {
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
return ctx
}
// ── G2: no embedder ⇒ no outbound HTTP, no extra deps ─────────────────────
/**
* Install a `fetch` tripwire for the duration of `body`, then restore it.
* @param {(count: () => number) => Promise<void>} body
*/
async function withFetchTripwire(body) {
const original = globalThis.fetch
let calls = 0
globalThis.fetch = (...args) => {
calls += 1
return Promise.reject(new Error(`unexpected fetch: ${args[0]}`))
}
try {
await body(() => calls)
} finally {
globalThis.fetch = original
}
}
test('G2: with no embedder, recall performs no network I/O', async () => {
// Any vector transport that tries to work without an embedder goes through here.
await withFetchTripwire(async (calls) => {
const store = new BlockStore({ path: ':memory:' }).open()
store.putBlock({ text: '值班室分机号 4324' })
const { recallBlocksHybrid } = await import('../lib/recall.js')
const hits = await recallBlocksHybrid(store, null, '4324', { topK: 3 })
assert.ok(hits.length >= 1, 'symbol path still recalls')
assert.equal(calls(), 0, `no embedder must mean zero network calls; saw ${calls()}`)
store.close()
})
})
// G2† — every early-degradation path must stay network-free.
//
// Rationale: G5 shipped a guard whose allowlist was silently always-false, and it
// stayed green because nothing ever exercised it. The same shape applies here —
// `recallBlocksHybrid` has FOUR early exits, and a hand-run check of one of them
// proves nothing about the other three. Enumerated as data so a fifth branch
// added later fails this test until it is covered.
const DEGRADATION_PATHS = [
{
name: 'embedder is null',
embedder: () => null,
expect: 'no_embedder',
},
{
name: 'embedder is undefined',
embedder: () => undefined,
expect: 'no_embedder',
},
{
name: 'embedder has no embedText',
embedder: () => ({ info: () => ({ fingerprint: 'fp', dimension: 3 }) }),
expect: 'no_embedder',
},
{
// ★ The branch this test was added for: non-null embedder, but it cannot
// name its own vector space. It must degrade WITHOUT calling embedText —
// and therefore without any transport being touched.
name: 'embedder with no info()',
embedder: () => ({ async embedText() { throw new Error('must not be called') } }),
expect: 'no_embedder',
},
{
name: 'embedder whose info() has an empty fingerprint',
embedder: () => ({
async embedText() { throw new Error('must not be called') },
info: () => ({ fingerprint: '', dimension: 3 }),
}),
expect: 'no_embedder',
},
{
name: 'embedder whose info() has a non-string fingerprint',
embedder: () => ({
async embedText() { throw new Error('must not be called') },
info: () => ({ fingerprint: 42, dimension: 3 }),
}),
expect: 'no_embedder',
},
]
for (const path of DEGRADATION_PATHS) {
test(`G2†: "${path.name}" degrades to ${path.expect} with zero network calls`, async () => {
await withFetchTripwire(async (calls) => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
store.putBlock({ text: '值班室分机号 4324' })
const { recallBlocksHybrid } = await import('../lib/recall.js')
const hits = await recallBlocksHybrid(store, path.embedder(), '4324', { topK: 3 })
assert.equal(hits.degraded, path.expect, `expected degraded=${path.expect}`)
assert.ok(hits.length >= 1, 'symbol path must still recall on every degradation path')
assert.equal(calls(), 0, `"${path.name}" must not touch the network; saw ${calls()}`)
} finally {
store.close()
}
})
})
}
test('G2†: the fingerprint check runs BEFORE embedText is called', async () => {
// Ordering, pinned directly: a failing-fingerprint embedder must never be
// asked for a vector. If a refactor moved the call earlier, this fails even
// though the degraded value would still be 'no_embedder'.
let embedCalls = 0
const embedder = {
async embedText() {
embedCalls += 1
return [0, 0, 0]
},
info: () => ({ fingerprint: '', dimension: 3 }),
}
const store = new BlockStore({ path: ':memory:' }).open()
try {
store.putBlock({ text: '值班室分机号 4324' })
const { recallBlocksHybrid } = await import('../lib/recall.js')
const hits = await recallBlocksHybrid(store, embedder, '4324', { topK: 3 })
assert.equal(hits.degraded, 'no_embedder')
assert.equal(embedCalls, 0, 'embedText must not be called when the space is unnamed')
} finally {
store.close()
}
})
// Scans SOURCE TEXT, not the loaded module. If it imported the module it would
// crash on a missing package and report "load failed" — which is a different
// finding than "a disallowed dependency was added", and the distinction matters
// when triaging.
// ── G3: commit must work with a dead embedder ────────────────────────────
test('G3: stablemem_commit writes even when the embedder throws', async () => {
const ctx = makeApp()
const dir = await import('node:fs').then((fs) => fs.mkdtempSync(join('/tmp', 'g3-')))
try {
const mem = applyMemory(ctx, { path: join(dir, 'm.db') })
mem.embedder = {
async embedText() {
throw new Error('vector service down')
},
info: () => ({ fingerprint: 'fp', dimension: 4 }),
}
const result = await ctx.tools.execute({
callId: 'g3',
name: 'stablemem_commit',
arguments: { text: '向量服务挂了也要能写入:端口 2379', source: 'g3' },
signal: new AbortController().signal,
})
assert.equal(result.isError, false, `commit must not fail: ${JSON.stringify(result)}`)
assert.equal(mem.store.countBlocks(), 1, 'the block must be stored')
// Recall still works through the symbol path.
const hits = await mem.recall('2379')
assert.ok(hits.length >= 1, 'symbol recall must still find it')
} finally {
await ctx.fiber.dispose()
const fs = await import('node:fs')
fs.rmSync(dir, { recursive: true, force: true })
}
})
// ── G4: media stays out ──────────────────────────────────────────────────
test('G4: only the text modality is produced', () => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
store.putBlock({ text: 'a note' })
const rows = store.allBlocks(10)
assert.equal(rows.length, 1)
for (const row of rows) {
assert.equal(row.modality, MODALITY_TEXT, 'no non-text modality may be written')
}
// No media payload columns exist at all in this slice, so there is nowhere
// for bytes to be stored even accidentally.
const columns = store.columns('memory_blocks')
for (const forbidden of ['data', 'mime', 'width', 'height']) {
assert.ok(
!columns.includes(forbidden),
`memory_blocks must have no media column; found "${forbidden}" in ${columns.join(',')}`,
)
}
} finally {
store.close()
}
})

4079
test/phase-b-h.test.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,817 @@
/**
* P1 · Vector recall wiring — the seam that was computed and then thrown away.
*
* ## The defect this file exists for
*
* `lib/recall.js:681` ended with
*
* return withDiagnosis(recallBlocks(store, query, scoped), 'vector_ready_unused')
*
* `queryVector` had been computed, dimension-checked, and then deliberately
* dropped: `scoped` carried only `topK` / `signal` / `fingerprint` / `dimension`.
* So every V1–V7 scoring branch in `recallBlocks` was live code reachable ONLY
* from unit tests that hand-built a `vectorScores` Map. In production the recall
* path was pure symbol/FTS — the whole vector half of phase B was unreachable.
*
* Grep evidence at c1f7e06: `vectorScores:` appears 10 times in
* `test/phase-b-a.test.js` and **zero** times anywhere under `lib/`.
*
* ## Why this criterion is end-to-end and not a function test
*
* A test that calls `recallBlocks(store, q, { vectorScores, minScore })` proves
* the scoring seam works — which was never in doubt. It cannot see the bug,
* because the bug is that nobody ever passes those two options. The only
* observation that distinguishes the broken build from the fixed one is on the
* far side of the whole chain:
*
* real sqlite file → real `apply()`/`commit()` write path → real
* `recallBlocksHybrid` → real `readOnly` query embedding → `agent/pre-step`
* injection → `renderRecall` → the system prompt → the model's answer
*
* So: a real `dsh --profile headless` process, a real on-disk database, an HTTP
* embedding service the plugin reaches through its own `lib/embed/http.js`
* adapter, and a stub LLM whose answer is a pure function of the system prompt it
* received. The assertion is on the **final answer text**, which is as far from
* the library boundary as the harness allows.
*
* ## Two preconditions, asserted before the answer is
*
* "The model answered QP47-812" alone would be satisfied by a lucky symbol hit.
* The claim P1 needs is *"the vector route is what carried it"*, so the same turn
* also proves:
*
* P1a symbol path only → zero hits, and specifically zero hits on block A
* (the diagnostic `degraded` mode is printed as evidence);
* P1b the injected text is attributable to the vector route (a `text`-tagged
* hit carrying a vector score, not an `exact` pin).
*
* Without P1a/P1b a passing test would only show that *something* answered.
*
* ★ What this does NOT establish.
* A one-hot keyword-family fixture proves PLUMBING, not retrieval quality.
* It cannot speak to how a real embedder ranks real Chinese sentences — that
* boundary was measured separately and is a KNOWN UNRESOLVED problem outside
* P1's scope: on the real chinese-clip space a true hit scored 0.8345 while an
* unrelated block scored 0.8598, so absolute scores are not calibrated and no
* threshold may be read off them. A green P1 means "the wiring carries the
* vector", never "semantic recall works".
*
* ## The corpus, and why these exact strings
*
* The fake embedder is a deterministic keyword-family projection into 8 unit
* axes: family 0 is the target's vocabulary, families 1..7 are one axis per
* distractor sentence. A text matching no family maps to the zero vector, so it
* can never be a nearest neighbour by accident. This makes the "does the vector
* route reach block A" question decidable with no model, no network, and no
* tolerance — and it is what the corpus was **measured** against before any
* assertion was written (see the pre-check test below, which is the executable
* form of that measurement).
*
* The access code is `QP47-812`, not a bare number, so the stub LLM's answer
* extractor (`[A-Z]{2}[0-9]{2}-[0-9]{3}`) can find it and nothing else in the
* prompt can collide with it.
*
* ## Missing-harness conditions
*
* The harness needs `dsh` resolvable from PATH. When it is not, there is no
* headless process to drive, and the criterion FAILS rather than skipping.
*
* ★ Why `assert.fail` and not `t.skip`: a skip is green. `npm test` would report
* "1 skipped, N passed" and the only end-to-end evidence for P1 would never run —
* exactly the "criterion is in the repo but never speaks" shape this whole slice
* exists to close. It is also worse than a plain hole, because a green suite reads
* as proof. `loop-system-e2e.test.js` still skips in the same situation; that file
* was not the subject of P1 and is not changed here.
*
* @module dsh-memory/test/phase-b-p1-vector-wiring
*/
import assert from 'node:assert/strict'
import { execFileSync, spawn } from 'node:child_process'
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
import { BlockStore } from '../lib/store.js'
import { recallBlocks, recallBlocksHybrid } from '../lib/recall.js'
// ── the corpus ────────────────────────────────────────────────────────────────
//
// One target ("A") and seven distractors. Every distractor owns exactly ONE
// embedding axis, and the target owns the other one, so a query can only reach A
// by landing on family 0.
/** Keyword families. Index 0 is the target's; 1..7 are one per distractor. */
const FAMILIES = [
['门禁码', 'QP47-812', '四位数', '暗号', '铁皮门', '货场', '进入凭证', '通行'],
['午饭', '食堂', '十二点半'],
['叉车', '额定载重', '两吨'],
['打印机', '缺纸', '红灯'],
['地铁', '园区北门'],
['例会', '会议室'],
['温度', '保护装置'],
['包裹', '收发室'],
]
const DIMENSION = FAMILIES.length
/** The one block the model must be able to answer with. */
const TARGET = '第七号仓库的备用门禁码是 QP47-812。'
/** The code that must appear in the final answer, and nowhere else by accident. */
const ANSWER = 'QP47-812'
const DISTRACTORS = [
'午饭时间改到十二点半,食堂在东楼二层。',
'叉车的额定载重是两吨,超了会翻。',
'打印机缺纸时会闪红灯,备件在储物间第三格。',
'地铁二号线直达园区北门,出站走五分钟。',
'每月的第二个周五开例会,会议室在行政楼。',
'温度超过八十五度,保护装置会切断电源。',
'包裹统一送去一楼收发室的货架,凭手机尾号取。',
]
/**
* The question the user asks.
*
* Measured against the corpus before being pinned here: it extracts no symbol
* (`querySymbols` → []), shares no CJK 2-gram with TARGET or any distractor, and
* FTS finds nothing for it. It reaches family 0 only through `四位数`.
*/
const QUERY = '上次说的那个四位数是什么来着?'
/** The query's family axis. Asserted so a corpus edit cannot silently break it. */
const QUERY_FAMILY = 0
/**
* Deterministic keyword-family embedding.
*
* Returns a one-hot unit vector on the first matching family, or the zero vector
* when nothing matches. Zero is deliberate: an unmapped text must not be a
* neighbour of anything, so a false "vector hit" cannot be an artefact of a
* default direction.
*
* @param {string} text
* @returns {number[]}
*/
function embedByFamily(text) {
const haystack = String(text ?? '')
const vector = new Array(DIMENSION).fill(0)
for (let index = 0; index < FAMILIES.length; index += 1) {
if (FAMILIES[index].some((keyword) => haystack.includes(keyword))) {
vector[index] = 1
return vector
}
}
return vector
}
/** Cosine similarity, written out so the fixture cannot depend on lib scoring. */
function cosine(a, b) {
let dot = 0
let normA = 0
let normB = 0
for (let index = 0; index < a.length; index += 1) {
dot += a[index] * b[index]
normA += a[index] * a[index]
normB += b[index] * b[index]
}
if (normA === 0 || normB === 0) return 0
return dot / (Math.sqrt(normA) * Math.sqrt(normB))
}
/** CJK 2-grams of a string, used to prove "no lexical overlap" mechanically. */
function bigrams(text) {
const chars = [...String(text ?? '')].filter((char) => /[\u3400-\u4dbf\u4e00-\u9fff]/.test(char))
const out = new Set()
for (let index = 0; index + 1 < chars.length; index += 1) out.add(chars[index] + chars[index + 1])
return out
}
// ── the fake embedding service ────────────────────────────────────────────────
/**
* An HTTP embedding endpoint speaking the plugin's own wire protocol.
*
* This is deliberately a real socket, not an injected `config.embedder`: the
* point of P1 is that the *production* path works, and the production path goes
* through `createHttpEmbedder` in `lib/embed/http.js`. Injecting an embedder
* object would leave that adapter — and the JSON shape it accepts — untested.
*
* @returns {Promise<{url: string, close: () => Promise<void>}>}
*/
async function startEmbedServer() {
const server = createServer((request, response) => {
const chunks = []
request.on('data', (chunk) => chunks.push(chunk))
request.on('end', () => {
let text = ''
try {
text = JSON.parse(Buffer.concat(chunks).toString('utf8')).text ?? ''
} catch {
response.writeHead(400).end('bad json')
return
}
const body = JSON.stringify({ embedding: embedByFamily(text) })
response.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) })
response.end(body)
})
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const { port } = server.address()
return {
url: `http://127.0.0.1:${port}/embed`,
close: () => new Promise((resolve) => server.close(resolve)),
}
}
// ── the stub LLM ──────────────────────────────────────────────────────────────
/**
* An OpenAI-compatible endpoint whose answer is a pure function of the SYSTEM
* prompt it receives.
*
* ★ Why a stub rather than a real provider: the assertion is "did block A reach
* the system prompt". A real model answers probabilistically, so a red run could
* mean "the model was unlucky" and a green run could mean "the model guessed the
* code". This stub makes the measurement exact: if the code is in the system
* prompt the answer IS the code; if it is not, the answer is 未知.
*
* It is still a real HTTP provider from dsh's point of view — the plugin, the
* harness, the request builder, and `agent/pre-step` are all the production ones.
*
* @returns {Promise<{url: string, close: () => Promise<void>}>}
*/
async function startStubLlm() {
const answerFor = (messages) => {
const system = messages
.filter((message) => message?.role === 'system')
.map((message) => (typeof message.content === 'string'
? message.content
: (message.content ?? []).map((block) => (typeof block?.text === 'string' ? block.text : '')).join('')))
.join('\n')
const found = system.match(/[A-Z]{2}\d{2}-\d{3}/)
return found === null ? '未知' : found[0]
}
const server = createServer((request, response) => {
if (request.method === 'GET') {
const body = JSON.stringify({ object: 'list', data: [{ id: 'stub-1', object: 'model' }] })
response.writeHead(200, { 'Content-Type': 'application/json' }).end(body)
return
}
const chunks = []
request.on('data', (chunk) => chunks.push(chunk))
request.on('end', () => {
let payload = {}
try {
payload = JSON.parse(Buffer.concat(chunks).toString('utf8'))
} catch {
response.writeHead(400).end('bad json')
return
}
const answer = answerFor(payload.messages ?? [])
const base = { id: 'chatcmpl-stub', created: 0, model: payload.model ?? 'stub-1' }
if (payload.stream === true) {
response.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'close',
})
const send = (chunk) => response.write(`data: ${JSON.stringify(chunk)}\n\n`)
send({ ...base, object: 'chat.completion.chunk', choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] })
send({ ...base, object: 'chat.completion.chunk', choices: [{ index: 0, delta: { content: answer }, finish_reason: null }] })
send({ ...base, object: 'chat.completion.chunk', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })
response.end('data: [DONE]\n\n')
return
}
response.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
...base,
object: 'chat.completion',
choices: [{ index: 0, message: { role: 'assistant', content: answer }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}))
})
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const { port } = server.address()
return {
url: `http://127.0.0.1:${port}/v1`,
close: () => new Promise((resolve) => server.close(resolve)),
}
}
// ── the headless harness ──────────────────────────────────────────────────────
/** True when a `dsh` binary is on PATH and resolves. */
function dshAvailable() {
try {
execFileSync('dsh', ['--version'], { stdio: 'ignore' })
return true
} catch {
return false
}
}
/**
* A private DSH_HOME with the stock headless profile's bundles.
*
* Written to a temp dir instead of reusing `$DSH_HOME` so the run cannot inherit
* whatever the developer has configured (a different embedder, a different memory
* database, a debug toggle). The only inputs are the ones this file supplies.
*
* @param {string} home
* @param {string} packageRoot
*/
function writeProfile(home, packageRoot) {
const profileDir = join(home, 'profiles', 'headless')
execFileSync('mkdir', ['-p', join(profileDir, 'node_modules')])
execFileSync('sh', ['-c', `ln -sfn ${JSON.stringify(packageRoot)} ${JSON.stringify(join(profileDir, 'node_modules', 'dsh-memory'))}`])
// An empty tree: the bundles in package.json supply the base rows, and the
// overlay supplies `memory`. Nothing from the developer's own profile leaks in.
execFileSync('sh', ['-c', `printf '[]\\n' > ${JSON.stringify(join(profileDir, 'cordis.yml'))}`])
execFileSync('sh', ['-c', `cat > ${JSON.stringify(join(profileDir, 'package.json'))} <<'P1PKG'
${JSON.stringify({
name: 'dsh-profile-headless',
private: true,
dependencies: { 'dsh-memory': `link:${packageRoot}` },
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'] } },
}, null, 2)}
P1PKG`])
return profileDir
}
/**
* Run one headless task and return the final answer text plus stderr.
*
* @param {object} options
* @param {string} options.home private DSH_HOME
* @param {string} options.profileDir
* @param {string} options.dbPath
* @param {string} options.embedUrl
* @param {string} options.llmUrl
* @param {string} options.query
* @param {string} options.cwd
* @returns {Promise<{answer: string, stderr: string, sessionId: string|null}>}
*/
function runHeadless({ home, profileDir, dbPath, embedUrl, llmUrl, query, cwd }) {
const overlay = join(profileDir, 'p1-overlay.yml')
// ★ `insert:` with an explicit `name:` and NOT a bare `- id: memory` merge.
// The private DSH_HOME written by {@link writeProfile} has no `memory` row of
// its own, so a merge-style patch would silently compose an entry with no
// plugin behind it: the config would appear in `--dump-config`, the harness
// would boot, and the recall hook would simply never be registered. That is a
// no-op that looks configured — the same shape as the bug this file pins.
// The two other rows are base entries and are legitimately merged by id.
const yaml = [
'- insert:',
' - id: memory',
' name: dsh-memory',
' config:',
` path: ${JSON.stringify(dbPath)}`,
' topK: 3',
' debugLog: true',
' inject: true',
' embed:',
` endpoint: ${JSON.stringify(embedUrl)}`,
` dimension: ${DIMENSION}`,
' model: p1-keyword-family',
'- id: agent-default-model',
' config:',
' provider: stub',
' model: stub-1',
'- id: llm-pi-ai',
' config:',
' providers:',
' stub:',
' displayName: stub',
' api: openai-completions',
` baseURL: ${JSON.stringify(llmUrl)}`,
' apiKeyEnv: P1_STUB_KEY',
' models:',
' - id: stub-1',
' name: stub-1',
' contextWindow: 65536',
// ── P0: the replacement loop must be mounted ────────────────────────────
//
// Without these two rows the run exercises the STOCK agent loop, which leaves
// the recall in user-message history. The recall would then never reach the
// system prompt, and P1's "the answer came from a system section" claim would
// be unprovable — worse, the assertion would pass for the wrong reason if the
// stub LLM were to look at history too (it deliberately does not, which is
// what makes the failure visible). This is byte-for-byte the shipped
// `cordis.patch.yml` overlay, so the run composes the same tree the profile
// does. It also pins the P0 result: 377e02c + c1f7e06 must survive P1.
'- id: agent-loop',
' disabled: true',
'- insert:',
' - id: hologram-agent-loop',
' name: dsh-memory/loop',
'',
].join('\n')
execFileSync('sh', ['-c', `cat > ${JSON.stringify(overlay)} <<'P1YAML'\n${yaml}P1YAML`])
return new Promise((resolve, reject) => {
const child = spawn(
'dsh',
['--profile', 'headless', '--patch', overlay, '--json', query],
{ cwd, env: { ...process.env, DSH_HOME: home, P1_STUB_KEY: 'p1-stub-key' } },
)
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk) => { stdout += chunk })
child.stderr.on('data', (chunk) => { stderr += chunk })
child.on('error', reject)
child.on('close', (code) => {
const events = stdout
.split('\n')
.filter((line) => line.trim() !== '')
.map((line) => {
try {
return JSON.parse(line)
} catch {
return null
}
})
.filter((event) => event !== null)
const final = events.findLast((event) => event.type === 'final')
const session = events.find((event) => event.type === 'session')
if (final === undefined) {
reject(new Error(`headless run produced no final event (exit ${code})\nstderr:\n${stderr}\nstdout:\n${stdout}`))
return
}
resolve({ answer: final.text ?? '', stderr, sessionId: session?.sessionId ?? null })
})
})
}
/**
* Decompress a session log and return its JSONL lines.
*
* `session.v4.jsonl.zstd` is what the harness actually persists, so it is what
* an operator would read. Parsing it (rather than trusting the run's own stdout)
* is the only way to see the injected prompt the model was really given.
*
* @param {string} home
* @param {string} sessionId
* @returns {string[]}
*/
function readSessionLines(home, sessionId) {
// The workspace key is a lossy slug of the launch cwd, so it cannot be
// reconstructed reliably (a real directory called `a-b` and one called `a/b`
// collide). Search for the session directory by its id instead — the id is
// exact and unique.
const sessionsRoot = join(home, 'sessions')
const candidates = existsSync(sessionsRoot)
? execFileSync('sh', ['-c', `find ${JSON.stringify(sessionsRoot)} -maxdepth 2 -type d -name ${JSON.stringify(sessionId)}`])
.toString('utf8')
.split('\n')
.filter((line) => line.trim() !== '')
: []
assert.equal(
candidates.length,
1,
`exactly one session directory must exist for ${sessionId} under ${sessionsRoot}; found ${JSON.stringify(candidates)}`,
)
const file = join(candidates[0], 'session.v4.jsonl.zstd')
assert.ok(existsSync(file), `session log must exist at ${file}`)
return execFileSync('zstd', ['-dc', file], { maxBuffer: 256 * 1024 * 1024 })
.toString('utf8')
.split('\n')
.filter((line) => line.trim() !== '')
}
/** Every system-prompt body in a session log, joined. */
function systemPromptFrom(lines) {
return lines
.map((line) => {
try {
return JSON.parse(line)
} catch {
return null
}
})
.filter((event) => event?.type === 'system/message')
.map((event) => (event.data?.message?.content ?? [])
.map((block) => (typeof block?.text === 'string' ? block.text : ''))
.join(''))
.join('\n')
}
// ── module setup shared by every case ─────────────────────────────────────────
const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url))
/** A store seeded through `putBlock` with family vectors, for the library-level checks. */
function seededStore() {
const store = new BlockStore({ path: ':memory:' }).open()
const fingerprint = 'p1-keyword-family'
const targetId = store.putBlock({ text: TARGET, source: 'p1', vector: embedByFamily(TARGET), fingerprint }).id
const distractorIds = DISTRACTORS.map((text) => store.putBlock({
text, source: 'p1', vector: embedByFamily(text), fingerprint,
}).id)
return { store, fingerprint, targetId, distractorIds }
}
// ══════════════════════════════════════════════════════════════════════════════
// P1-0 · corpus pre-check — the executable form of "measured before asserted"
// ══════════════════════════════════════════════════════════════════════════════
/**
* The pre-check that must pass BEFORE the criterion is allowed to assert
* anything. It fails on a corpus problem, never on the wiring bug, and it is
* kept in the suite so a later corpus edit cannot quietly make the e2e
* criterion unfalsifiable.
*
* Two conditions, both mandated:
* 1. the symbol path returns NO hit for the target (so a green answer can
* only be the vector route);
* 2. with a hand-built `vectorScores` Map plus `minScore: 0`, the target IS
* top-1 (so the corpus and vectors can actually reach it at all).
*/
test('P1-0: the corpus is admissible — symbol path blind, vector path reaches the target', () => {
const { store, targetId } = seededStore()
try {
// ── the query really does extract no symbol and share no 2-gram ──────────
const queryVector = embedByFamily(QUERY)
assert.equal(
queryVector.indexOf(1),
QUERY_FAMILY,
`the query must land on family ${QUERY_FAMILY}; got ${JSON.stringify(queryVector)}`,
)
const queryGrams = bigrams(QUERY)
for (const text of [TARGET, ...DISTRACTORS]) {
const shared = [...bigrams(text)].filter((gram) => queryGrams.has(gram))
assert.deepEqual(
shared,
[],
`precondition: the query must share no CJK 2-gram with "${text}"; shared ${JSON.stringify(shared)}`,
)
}
// ── condition 1: the symbol path is blind to the target ─────────────────
const symbolOnly = recallBlocks(store, QUERY, { topK: 5 })
assert.equal(
symbolOnly.filter((hit) => hit.id === targetId).length,
0,
`precondition 1: the symbol path must NOT find the target; it returned ${JSON.stringify(
symbolOnly.map((hit) => [hit.text, hit.score]),
)}`,
)
// ── condition 2: a manually built vector route DOES reach the target ────
const scores = new Map(store.allBlocks(-1).map((block) => [
block.id,
cosine(queryVector, JSON.parse(block.vector)),
]))
const fused = recallBlocks(store, QUERY, { topK: 3, vectorScores: scores, minScore: 0 })
assert.ok(fused.length > 0, 'precondition 2: the fused route must return something')
assert.equal(
fused[0].id,
targetId,
`precondition 2: the target must be top-1 once vectors participate; got ${
JSON.stringify(fused.map((hit) => [hit.text, hit.score]))
}`,
)
} finally {
store.close()
}
})
// ══════════════════════════════════════════════════════════════════════════════
// P1 · the end-to-end criterion
// ══════════════════════════════════════════════════════════════════════════════
test('P1: a semantic-only question is answered from block memory via the vector route', async (t) => {
if (!dshAvailable()) {
assert.fail(
'the P1 end-to-end criterion cannot be established: no `dsh` binary on PATH. ' +
'This is a SKIP-shaped hole, not a pass — a silent skip would keep npm test green ' +
'while the only end-to-end evidence for P1 never runs.',
)
}
const workspace = mkdtempSync(join(tmpdir(), 'p1-e2e-'))
const home = join(workspace, 'home')
const cwd = join(workspace, 'work')
execFileSync('mkdir', ['-p', cwd])
const profileDir = writeProfile(home, PACKAGE_ROOT)
const dbPath = join(workspace, 'memory.db')
const embedServer = await startEmbedServer()
const llmServer = await startStubLlm()
try {
// ── seed through the real plugin write path ──────────────────────────────
//
// `apply()` builds the plugin exactly as the profile does, `commit()` runs
// the production embed → `putBlock(vector, fingerprint)` path. Nothing here
// writes a vector by hand: a block that reached the database without the
// real write path could not prove the read path is wired.
const { Context } = await import('@deepseek-ai/cordis')
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
const { apply: applyMemory } = await import('../lib/index.js')
const seedCtx = new Context()
let space
try {
new SystemPrompt(seedCtx, {})
new ToolRuntime(seedCtx)
await new Promise((resolve) => setTimeout(resolve, 0))
const mem = applyMemory(seedCtx, {
path: dbPath,
topK: 3,
debugLog: false,
inject: true,
embed: { endpoint: embedServer.url, dimension: DIMENSION, model: 'p1-keyword-family' },
})
await new Promise((resolve) => setTimeout(resolve, 0))
space = mem.space()
assert.ok(space !== null && space.dimension === DIMENSION, `the plugin must expose the fake space; got ${JSON.stringify(space)}`)
for (const text of [TARGET, ...DISTRACTORS]) {
const written = await mem.commit({ text, source: 'p1-seed' })
assert.equal(
written.embedded,
true,
`seed block must be stored WITH a vector (real embed path), not symbol-only: ${text}`,
)
}
assert.equal(mem.store.countBlocks(), 1 + DISTRACTORS.length, 'every corpus block must be on disk')
} finally {
await seedCtx.fiber.dispose()
}
// ── P1a · the symbol path is blind, proved on the real on-disk database ──
const store = new BlockStore({ path: dbPath }).open()
let symbolHits
try {
symbolHits = recallBlocks(store, QUERY, { topK: 5, fingerprint: space.fingerprint, dimension: space.dimension })
} finally {
store.close()
}
assert.equal(
symbolHits.length,
0,
`P1a precondition: the symbol path must find NOTHING for this query; got ${JSON.stringify(
symbolHits.map((hit) => [hit.text, hit.score]),
)}`,
)
// The current build's own diagnostic, captured before the run: on c1f7e06 this
// is `vector_ready_unused` and the array is empty.
const hybridStore = new BlockStore({ path: dbPath }).open()
let hybridDiagnosis
try {
const probe = await import('../lib/recall.js')
const hybrid = await probe.recallBlocksHybrid(
hybridStore,
{
embedText: async (text) => embedByFamily(text),
info: () => space,
},
QUERY,
{ topK: 3 },
)
hybridDiagnosis = { mode: hybrid.degraded ?? 'ok', hits: hybrid.length }
} finally {
hybridStore.close()
}
// ── the real headless run ────────────────────────────────────────────────
const run = await runHeadless({
home, profileDir, dbPath, embedUrl: embedServer.url, llmUrl: llmServer.url, query: QUERY, cwd,
})
// ── the injected text, recovered from the persisted session log ──────────
//
// Everything below is derived from the harness's own on-disk record, not from
// the run's stdout: the session log carries the exact system prompt the
// adapter was handed, which is the only place the injection is observable.
assert.ok(
run.sessionId !== null,
`the run must report its session id; stderr was:\n${run.stderr}`,
)
const lines = readSessionLines(home, run.sessionId)
const systemPrompt = systemPromptFrom(lines)
// The recall SECTION, not just its header line: `renderRecall` emits a header
// and then one line per hit, so the entry lines (which carry the `text` tag and
// the score) live BELOW the header. Taking the section rather than the first
// matching line is what lets P1c assert on the hit's shape.
const systemLines = systemPrompt.split('\n')
const recallStart = systemLines.findIndex((line) => line.includes('Recalled from durable block memory'))
const recallLine = recallStart === -1 ? undefined : systemLines.slice(recallStart).join('\n')
t.diagnostic(`P1 symbol-path baseline: hits=${symbolHits.length}`)
t.diagnostic(`P1 recall diagnosis before the run: degraded=${hybridDiagnosis.mode} hits=${hybridDiagnosis.hits}`)
t.diagnostic(`P1 recall line injected: ${recallLine ?? '(none)'}`)
// ── P1 · the answer ──────────────────────────────────────────────────────
//
// The primary assertion. On c1f7e06 the recall array is empty (`hits=0`), so
// the stub LLM — which answers ONLY with a code it finds in the system prompt
// — replies 未知. That failure IS the defect: the vector was computed and
// thrown away.
assert.equal(
run.answer.trim(),
ANSWER,
`P1: the answer must come from block memory through the vector route.\n` +
` query: ${QUERY}\n` +
` expected: ${ANSWER}\n` +
` actual: ${JSON.stringify(run.answer)}\n` +
` symbol-path hits: ${symbolHits.length} (must be 0)\n` +
` recall diagnosis: degraded=${hybridDiagnosis.mode} hits=${hybridDiagnosis.hits}\n` +
` recall section injected: ${recallLine ?? '(none)'}`,
)
// ── P1b · the recall reached the SYSTEM prompt, not user history (P0) ────
//
// P0 (377e02c + c1f7e06) must still hold after the wiring change: the recall
// is a system *section*, so it may appear in no non-system message body.
assert.ok(
recallLine !== undefined,
`P1b: the recall section must reach the system prompt (P0 must still hold). ` +
`degraded=${hybridDiagnosis.mode} symbolHits=${symbolHits.length}\n` +
`stderr:\n${run.stderr}`,
)
const leakingBodies = lines
.map((line) => {
try {
return JSON.parse(line)
} catch {
return null
}
})
.filter((event) => event?.type === 'user/message' || event?.type === 'assistant/message')
.map((event) => JSON.stringify(event.data ?? {}))
.filter((body) => body.includes(TARGET))
const recallCarrierMessages = (() => {
// Count carriers by role across every message the harness recorded.
const carriers = []
for (const line of lines) {
let event
try {
event = JSON.parse(line)
} catch {
continue
}
const message = event?.data?.message ?? event?.data
const text = (message?.content ?? [])
.map((block) => (typeof block?.text === 'string' ? block.text : ''))
.join('')
if (typeof message?.role === 'string' && text.includes(TARGET)) carriers.push(message.role)
}
return carriers
})()
t.diagnostic(`P1b recall carriers by role: ${JSON.stringify(recallCarrierMessages)}`)
t.diagnostic(`P1b non-system bodies containing the target: ${JSON.stringify(leakingBodies)}`)
assert.deepEqual(
leakingBodies,
[],
'P1b: the recalled note must NOT appear in any user/assistant message body (a move, not a copy)',
)
// Positive half of the same claim: exactly ONE message carries it, and that
// message is the system prompt. Without this, a build that dropped the recall
// from the prompt entirely could still satisfy the negative assertion above —
// and P0 is precisely the property that must survive P1.
assert.deepEqual(
recallCarrierMessages,
['system'],
`P1b: the recalled note must be carried by exactly one message, the system prompt; got ${JSON.stringify(
recallCarrierMessages,
)}`,
)
// ── P1c · the hit is a VECTOR hit, not an exact symbol pin ──────────────
//
// `renderRecall` tags a hit `(exact 1.0x)` when a numeric symbol pinned it and
// `(text 0.xx)` otherwise. Our corpus guarantees no symbol match, so a `text`
// tag with a sub-1.0 score is the observable signature of the fused vector
// route (`vec*0.7 + sym*0.3`). If the wiring regressed to a symbol-only path
// this tag would read `exact`, and if vectors stopped participating entirely
// there would be no line at all (caught by P1 above).
assert.match(
recallLine,
/\[\d+\] \(text 0\.\d{2}\) .*QP47-812/,
`P1c: the injected entry must be a \`text\`-tagged hit (vector route) carrying the target, ` +
`not an \`exact\` symbol pin. recall line was: ${recallLine}\n` +
`diagnosis at probe time: degraded=${hybridDiagnosis.mode} hits=${hybridDiagnosis.hits}`,
)
} finally {
await embedServer.close()
await llmServer.close()
// ★ `P1_KEEP=1` keeps the workspace so an operator (or a review) can read the
// session log, the sqlite file and the composed overlay by hand. Off by
// default: a test that leaves databases behind on every run turns /tmp into a
// second, unaudited store of memory. The directory is printed either way, so a
// preserved run is discoverable from the test output alone.
if (process.env.P1_KEEP === '1') {
t.diagnostic(`P1 workspace kept: ${workspace}`)
console.log(`P1 workspace kept: ${workspace}`)
} else {
rmSync(workspace, { recursive: true, force: true })
}
}
})

View File

@ -0,0 +1,153 @@
/**
* V6 · 写入侧与查询侧必须用不同的 `side`。
*
* ## 病
*
* HA 的 HTTP 嵌入协议带一个 `side` 字段(`query` / `document`,
* `http_embedder.go:105` / `:109`;枚举 `PurposeQuery` / `PurposeDocument`)。
* 检索类模型通常对两侧做不同的编码——存进来的文档向量与提问的查询向量落在
* 同一个坐标系的**不同子空间**里。两侧都用 `query` 时,块向量和查询向量都
* 是「正确宽度、正确模长的单位向量」,所以没有任何东西会报错:只是在真正
* 需要语义匹配时对不上。
*
* 移植时 `side` 被做进了 adapter 的构造参数(默认 `query`),但**没有一个
* 调用点传它**:写入路径与召回路径都走默认值。于是「文档按查询侧编码」这件
* 事发生了,且无法从任何返回值上观察到。
*
* ## 判据形态
*
* 起一个真的 HTTP 服务,把每个请求体里的 `side` 记下来,然后用**生产装配**
* (`apply()` + `config.embed`)跑一次写入和一次召回:
*
* commit → 请求体 side 必须是 'document'
* recall → 请求体 side 必须是 'query'
*
* 断言落在**线上真正发出去的字段**,不是内部变量。变异(写入侧改回共用
* 查询实例)会让 commit 的 side 变回 'query',这条立刻红。
*
* ★ 为什么不用注入式 embedder:注入的是单个对象,没有「两个编码器」可言,
* 绕开它就把被测的装配逻辑整个跳过了。这条判据必须走 HTTP 装配。
*
* @module dsh-memory/test/phase-b-v6-sides
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { createServer } from 'node:http'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const DIM = 4
const unit = (v) => { const n = Math.hypot(...v); return n === 0 ? v : v.map((x) => x / n) }
const vecFor = (text) => unit([1, String(text).length % 7 === 0 ? 0.4 : 0.2, 0.1, 0])
/** 起一个记录 `side` 的嵌入服务。 */
async function startRecordingEmbedder() {
const seen = []
const server = createServer((req, res) => {
const chunks = []
req.on('data', (c) => chunks.push(c))
req.on('end', () => {
let payload = {}
try { payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { /* ignore */ }
seen.push({ side: payload.side, modality: payload.modality, text: payload.text })
const body = JSON.stringify({ embedding: vecFor(payload.text) })
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) })
res.end(body)
})
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
return {
url: `http://127.0.0.1:${server.address().port}/embed`,
seen,
close: () => new Promise((resolve) => server.close(resolve)),
}
}
test('V6: storing a block encodes it on the document side, asking encodes the query side', async () => {
const { Context } = await import('@deepseek-ai/cordis')
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
const { apply } = await import('../lib/index.js')
const embed = await startRecordingEmbedder()
const dir = mkdtempSync(join(tmpdir(), 'v6-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
try {
// ★ 生产装配:给它 config.embed,让它自己建两个编码器。
const mem = apply(ctx, {
path: join(dir, 'm.db'),
embed: { endpoint: embed.url, dimension: DIM, model: 'v6-probe' },
})
// ── 写入侧 ────────────────────────────────────────────────────────────
const written = await mem.commit({ text: '值班室的分机号是 4324' })
assert.equal(written.embedded, true, 'precondition: the block must actually be embedded')
const afterWrite = embed.seen.at(-1)
assert.equal(
afterWrite?.side,
'document',
'V6: a block being STORED must be encoded on the document side. Both sides '
+ 'defaulting to "query" is invisible at runtime — the vectors have the right '
+ 'width and length either way — and it silently puts stored memories in a '
+ `different subspace from the questions. got side=${JSON.stringify(afterWrite?.side)}`,
)
assert.equal(afterWrite.modality, 'text', 'and the modality field still travels')
// ── 召回侧 ────────────────────────────────────────────────────────────
const before = embed.seen.length
await mem.recall('分机号是多少')
const afterRecall = embed.seen.slice(before)
assert.equal(afterRecall.length, 1, `precondition: the recall must embed exactly once; got ${afterRecall.length}`)
assert.equal(
afterRecall[0].side,
'query',
'V6: a QUESTION must be encoded on the query side',
)
// ── 两侧的指纹必须一致,否则块与中心会落在两个空间里 ────────────────
assert.equal(
mem.space().dimension,
DIM,
'V6: the two encoders must still describe ONE space (the fingerprint feeds '
+ 'both memory_blocks and graph_centroid)',
)
} finally {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
await embed.close()
}
})
test('V6: an injected single embedder keeps working and is used for both sides', async () => {
const { Context } = await import('@deepseek-ai/cordis')
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
const { apply } = await import('../lib/index.js')
const dir = mkdtempSync(join(tmpdir(), 'v6-inj-'))
let calls = 0
const injected = {
async embedText() { calls += 1; return vecFor('x') },
info: () => ({ fingerprint: 'v6:injected:test', dimension: DIM }),
}
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
try {
// 注入式 embedder 没有「两个编码器」可言:它必须照旧被两侧共用,
// 否则所有既有测试与单侧适配器都会被打断。
const mem = apply(ctx, { path: join(dir, 'm.db'), embedder: injected })
const written = await mem.commit({ text: '值班室的分机号是 4324' })
assert.equal(written.embedded, true)
await mem.recall('分机号是多少')
assert.ok(calls >= 2, `the single injected embedder must serve both paths; got ${calls} calls`)
} finally {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
}
})

549
test/plugin.test.js Normal file
View File

@ -0,0 +1,549 @@
/**
* Plugin-wiring tests against **real** dsh services.
*
* These are the assertions that would silently rot if dsh changed a hook, so they
* run the actual `SystemPrompt` and `ToolRuntime` rather than stubs. The
* checkpoint script covers the same ground with human-readable output; this file
* is what CI should run.
*
* Run: node --test test/
*
* @module dsh-memory/test/plugin
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { Context } from '@deepseek-ai/cordis'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
import { ToolRuntime } from '@deepseek-ai/dsh-tools'
import { apply as applyMemory, lastUserText, name as pluginName } from '../lib/index.js'
/**
* Build a real cordis app carrying the services this plugin injects.
* `ToolRuntime` declares `inject = ['systemPrompt']`, so prompt comes first.
* @returns {Promise<{ctx: Context, dir: string, done: () => void}>}
*/
async function app() {
const dir = mkdtempSync(join(tmpdir(), 'dsh-memory-plugin-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
return {
ctx,
dir,
done: async () => {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
},
}
}
/** A realistic dsh step batch: question, then two user-role injected contexts. */
function realBatch(question) {
return [
{
id: 'm1',
role: 'user',
source: { kind: 'user' },
content: [{ type: 'text', text: question }],
},
{
id: 'm2',
role: 'user',
source: { kind: 'runtime-context', form: 'snapshot' },
content: [{ type: 'text', text: '[Working directory]\n/home/agent/project' }],
},
{
id: 'm3',
role: 'user',
source: { kind: 'skill-catalog', form: 'catalog' },
content: [{ type: 'text', text: '[Skills] git-release-discipline, android-cli, shared-browser' }],
},
]
}
test('lastUserText picks the newest HUMAN prompt and joins only text blocks', () => {
// Every real message carries `source.kind`; the fixtures must too, or the test
// does not exercise the actual discrimination (see the REGRESSION block below).
const messages = [
{ role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: 'first' }] },
{ role: 'assistant', source: { kind: 'model' }, content: [{ type: 'text', text: 'reply' }] },
{
role: 'user',
source: { kind: 'user' },
content: [{ type: 'tool-result' }, { type: 'text', text: 'second' }],
},
]
assert.equal(lastUserText(messages), 'second')
assert.equal(lastUserText([]), '')
assert.equal(
lastUserText([{ role: 'assistant', source: { kind: 'model' }, content: [{ type: 'text', text: 'x' }] }]),
'',
)
})
// ── G6: the allowlist must be proven against REAL injected kinds ───────────
//
// Scope note (important): this table is **not** claimed to be exhaustive. It is
// "every user-role injected kind declared in the currently installed harness".
// The full `MessageSourceMap` surface is larger (25 source keys at the time of
// writing), and more will appear. That is fine — `HUMAN_PROMPT_KINDS` is an
// allowlist, so an unlisted future kind fails closed by construction, which is
// the property that actually matters at runtime.
//
// What this table DOES prove: the names we think are injected really are, and a
// rename in the harness breaks a test here instead of silently killing recall.
//
// Sources (each verified as a literal declaration, not inferred):
// agent-instructions dsh-agent-instructions/lib/types/state.d.ts:27
// agent-message dsh-subagent/lib/types/continuation-messages.d.ts:37
// compact-checkpoint dsh-compaction/lib/types/checkpoint.d.ts:18
// cordis-host-runner dsh-cordis-host-runner/lib/types/index.d.ts:13
// dsh-session-title-llm dsh-session-title-llm/lib/types/index.d.ts:14
// model-selection dsh-agent/lib/types/model-selection.d.ts:11
// plan-mode dsh-plan-mode/lib/types/index.d.ts:31
// ptc-mode dsh-tools/lib/types/ptc.d.ts:14
// repeat-tool-reminder dsh-repeat-tool-reminder/lib/types/index.d.ts:14
// runtime-context dsh-agent-loop/lib/types/runtime-context.d.ts:13
// schedule dsh-schedule/lib/types/runtime.d.ts:5
// session-reference dsh-session-reference/lib/types/types.d.ts:33
// skill-catalog dsh-tool-skill/lib/types/index.d.ts:29
// skill-invocation dsh-skill/lib/types/index.d.ts:131
// subagent-settled dsh-subagent/lib/types/continuation-messages.d.ts:38
// team-message dsh-experimental-agent-team/lib/types/types.d.ts:123
// time-context dsh-time-context/lib/types/index.d.ts:14
// tmux-context dsh-tmux-context/lib/types/index.d.ts
// tool-goal dsh-tool-goal/lib/types/index.d.ts:12
// tool-jobs dsh-tool-jobs/lib/types/index.d.ts:16
// tool-registry dsh-tools/lib/types/index.d.ts:20
// user-approval dsh-user-approval/lib/types/index.d.ts:14
// working-directory dsh-working-directory/lib/types/index.d.ts
const REAL_INJECTED_KINDS = [
'agent-instructions',
'agent-message',
'compact-checkpoint',
'cordis-host-runner',
'dsh-session-title-llm',
'model-selection',
'plan-mode',
'ptc-mode',
'repeat-tool-reminder',
'runtime-context',
'schedule',
'session-reference',
'skill-catalog',
'skill-invocation',
'subagent-settled',
'team-message',
'time-context',
'tmux-context',
'tool-goal',
'tool-jobs',
'tool-registry',
'user-approval',
'working-directory',
]
test('G6: every user-role injected kind in the installed harness is rejected as a query', () => {
for (const kind of REAL_INJECTED_KINDS) {
const batch = [
{ role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: '真正的问题' }] },
{ role: 'user', source: { kind }, content: [{ type: 'text', text: `[${kind}] injected body` }] },
]
assert.equal(
lastUserText(batch),
'真正的问题',
`kind "${kind}" must be treated as injected, not as the human prompt`,
)
}
})
test('G6 mutation: "role is user ⇒ accept" must fail on every real injected kind', () => {
// The mutation is the exact first implementation: no kind filter at all.
const roleOnly = (messages) => {
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i]?.role !== 'user') continue
const t = (messages[i].content ?? [])
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
.map((b) => b.text)
.join('\n')
.trim()
if (t !== '') return t
}
return ''
}
let mutatedFailures = 0
for (const kind of REAL_INJECTED_KINDS) {
const batch = [
{ role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: '真正的问题' }] },
{ role: 'user', source: { kind }, content: [{ type: 'text', text: `[${kind}] injected body` }] },
]
assert.equal(lastUserText(batch), '真正的问题')
if (roleOnly(batch) !== '真正的问题') mutatedFailures += 1
}
assert.equal(
mutatedFailures,
REAL_INJECTED_KINDS.length,
`the mutation must fail on ALL ${REAL_INJECTED_KINDS.length} kinds, failed on ${mutatedFailures}`,
)
})
// ── G6b: the allowlist must NOT exclude a real human prompt ───────────────
//
// This is the safety half of G6, and it is the reason a strict allowlist is
// acceptable at all. A remote prompt arrives with a DIFFERENT source key
// (`user-rpc`) but the SAME kind (`user`):
//
// dsh-api-session-controller/lib/types/types.d.ts:395-400
// 'user-rpc': { kind: 'user'; rpcId: SessionRequestId; clientTimeZone?: string; principal?: Principal }
//
// So `kind: 'user'` covers both the local prompt and the browser/RPC prompt.
// Without this assertion, a future tightening of the allowlist (e.g. switching
// to key-based filtering) could silently stop recalling for web users only.
test('G6b: a user-rpc prompt (kind "user", extra fields) IS accepted', () => {
const rpc = {
role: 'user',
source: {
kind: 'user',
rpcId: 'rpc-123',
clientTimeZone: 'Asia/Shanghai',
principal: { id: 'other-caller' },
},
content: [{ type: 'text', text: '远程提问:值班室分机号是多少' }],
}
assert.equal(
lastUserText([rpc]),
'远程提问:值班室分机号是多少',
'user-rpc carries kind "user" and must be treated as a human prompt',
)
// And it still wins over an injected message that arrives after it.
const batch = [
rpc,
{ role: 'user', source: { kind: 'runtime-context' }, content: [{ type: 'text', text: '[wd]' }] },
]
assert.equal(lastUserText(batch), '远程提问:值班室分机号是多少')
})
// ── G7: a message with no source at all must yield '' ─────────────────────
//
// Pinned explicitly rather than left to inference. Reachability note: the
// current harness makes this state unreachable — `assertMessageEventShape`
// rejects `user/message` whose `source.kind` is not a non-empty string, and it
// runs on BOTH the append path (dsh-session/lib/index.js:1089) and the
// seed/restore path (:1165). So this is defense in depth against a future
// format/migration change, not a live bug. The assertion still earns its keep:
// it is the guard that keeps a missing `source` from silently degrading to
// "use whatever the last user-role message says".
test('G7: kind === undefined (and any non-string kind) yields ""', () => {
const cases = [
{ name: 'source missing entirely', message: { role: 'user', content: [{ type: 'text', text: 'q' }] } },
{ name: 'source is null', message: { role: 'user', source: null, content: [{ type: 'text', text: 'q' }] } },
{ name: 'source.kind missing', message: { role: 'user', source: {}, content: [{ type: 'text', text: 'q' }] } },
{
name: 'source.kind not a string',
message: { role: 'user', source: { kind: 42 }, content: [{ type: 'text', text: 'q' }] },
},
{
name: 'source.kind empty string',
message: { role: 'user', source: { kind: '' }, content: [{ type: 'text', text: 'q' }] },
},
]
for (const { name, message } of cases) {
assert.equal(lastUserText([message]), '', `${name} must yield "" (fail closed)`)
}
})
test('G7 mutation: dropping the typeof guard admits a sourceless message', () => {
// The mutation: `HUMAN_PROMPT_KINDS.has(undefined)` is false, so removing the
// typeof check would NOT by itself admit undefined — the real risk is a
// future refactor to `kind !== 'user'` negation or a role-only fallback.
// Pin the behaviour that the guard exists to produce:
const mutated = (messages) => {
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i]?.role !== 'user') continue
// Mutation: no source check at all.
const t = (messages[i].content ?? [])
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
.map((b) => b.text)
.join('\n')
.trim()
if (t !== '') return t
}
return ''
}
const sourceless = [{ role: 'user', content: [{ type: 'text', text: 'q' }] }]
assert.equal(lastUserText(sourceless), '', 'guarded implementation rejects it')
assert.equal(mutated(sourceless), 'q', 'unguarded implementation admits it — G7 catches this')
})
test('V3: systemPrompt.context exposes agent but no step messages', async () => {
const { ctx, done } = await app()
try {
let keys = []
ctx.systemPrompt.context({
name: 'probe:v3',
order: 210,
text: (context) => {
keys = Object.keys(context).sort()
return 'probe'
},
})
await ctx.systemPrompt.assemble({ agent: { id: 'a', session: {} } })
assert.ok(keys.includes('agent'), `AssembleContext must carry agent; got ${keys.join(',')}`)
// The negative assertion is the load-bearing one: if dsh ever adds the step
// messages here, recall could move to this hook and this slice's whole
// design note would be wrong.
for (const forbidden of ['messages', 'input', 'text']) {
assert.ok(!keys.includes(forbidden), `AssembleContext must NOT expose ${forbidden}`)
}
} finally {
await done()
}
})
test('V3b: pre-step injection adds ONE attributed message after a real batch', async () => {
const { ctx, dir, done } = await app()
try {
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), topK: 3 })
await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' })
// Uses realBatch, not a hand-built single message: the whole lesson of the
// regression above is that a 1- or 2-message batch is a fake batch.
const batch = realBatch('值班室分机号 4324 是谁在值班')
const decision = await ctx.waterfall(
'agent/pre-step',
{
agent: { id: 'a', session: {} },
messages: batch,
turn: 1,
step: 1,
signal: new AbortController().signal,
},
() => Promise.resolve({ kind: 'enter', messages: batch }),
)
assert.equal(decision.kind, 'enter')
assert.equal(decision.messages.length, batch.length + 1, 'batch + exactly one injected recall')
const injected = decision.messages[batch.length]
assert.equal(injected.role, 'user', 'injected context is a user message')
assert.equal(injected.source.kind, pluginName)
assert.equal(injected.source.form, 'snapshot')
assert.ok(injected.content.map((b) => b.text ?? '').join('').includes('4324'))
} finally {
await done()
}
})
test('V3b mutation: with recall silenced the batch stays un-augmented', async () => {
const { ctx, dir, done } = await app()
try {
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), topK: 3 })
await mem.commit({ text: '值班室分机号 4324,值班人 老周' })
mem.contextForStep = () => ''
const input = createUserMessage({
content: [{ type: 'text', text: '值班室分机号 4324' }],
source: { kind: 'user' },
})
const decision = await ctx.waterfall(
'agent/pre-step',
{
agent: { id: 'a', session: {} },
messages: [input],
turn: 1,
step: 1,
signal: new AbortController().signal,
},
() => Promise.resolve({ kind: 'enter', messages: [input] }),
)
assert.equal(decision.messages.length, 1, 'no injection when recall yields nothing')
} finally {
await done()
}
})
test('V3b: a rejected step is passed through untouched', async () => {
const { ctx, dir, done } = await app()
try {
applyMemory(ctx, { path: join(dir, 'm.db') })
const decision = await ctx.waterfall(
'agent/pre-step',
{
agent: { id: 'a', session: {} },
messages: [],
turn: 1,
step: 1,
signal: new AbortController().signal,
},
() => Promise.resolve({ kind: 'reject' }),
)
assert.equal(decision.kind, 'reject', 'a rejection must not be converted into an enter')
} finally {
await done()
}
})
test('V4: stablemem_commit registers with a schema and executes end to end', async () => {
const { ctx, dir, done } = await app()
try {
const mem = applyMemory(ctx, { path: join(dir, 'm.db') })
const schemas = ctx.tools.schemas()
const tool = schemas.find((entry) => entry.name === 'stablemem_commit')
assert.ok(tool, 'tool must be visible to the model')
assert.ok(tool.description.length > 0, 'tool must carry a description')
assert.ok(tool.parameters?.properties?.text, 'tool must declare its text parameter')
const result = await ctx.tools.execute({
callId: 'c1',
name: 'stablemem_commit',
arguments: { text: 'etcd 集群端口 2379', source: 'test' },
signal: new AbortController().signal,
})
assert.equal(result.isError, false, JSON.stringify(result))
assert.ok(String(result.value).includes('Committed'), result.value)
assert.equal(result.content[0].type, 'text', 'render must produce a text block')
assert.equal(mem.store.countBlocks(), 1)
} finally {
await done()
}
})
test('V5: disposing the fiber closes the store and releases the file', async () => {
const { ctx, dir, done } = await app()
const path = join(dir, 'm.db')
const mem = applyMemory(ctx, { path })
await mem.commit({ text: 'V5 句柄释放块' })
assert.equal(mem.store.isOpen, true)
await ctx.fiber.dispose()
assert.equal(mem.store.isOpen, false, 'fiber disposal must close the handle')
// Strongest proof: reopen the same file for writing.
const { BlockStore } = await import('../lib/store.js')
const reopened = new BlockStore({ path }).open()
assert.equal(reopened.countBlocks(), 1)
reopened.close()
rmSync(dir, { recursive: true, force: true })
void done
})
test('config.inject:false registers the tool but no pre-step listener', async () => {
const { ctx, dir, done } = await app()
try {
applyMemory(ctx, { path: join(dir, 'm.db'), inject: false })
const input = createUserMessage({
content: [{ type: 'text', text: 'anything' }],
source: { kind: 'user' },
})
const decision = await ctx.waterfall(
'agent/pre-step',
{
agent: { id: 'a', session: {} },
messages: [input],
turn: 1,
step: 1,
signal: new AbortController().signal,
},
() => Promise.resolve({ kind: 'enter', messages: [input] }),
)
assert.equal(decision.messages.length, 1, 'injection must be off')
assert.ok(ctx.tools.schemas().some((s) => s.name === 'stablemem_commit'), 'tool still present')
} finally {
await done()
}
})
// ── REGRESSION: auto-recall died on a realistic dsh batch ──────────────────
//
// Found by running the plugin in a real headless profile, not by unit test.
// The unit test above passes because it builds a 2-message batch; a REAL step
// batch has additional user-role messages appended AFTER the question:
//
// [ user question ][ runtime-context snapshot ][ skill catalog reminder ]
//
// `lastUserText` used to return the last `role === 'user'` message, which is the
// skill catalog — so every real turn used a list of skill names as its recall
// query and matched nothing. Auto-recall was 100% dead in production while all
// checkpoints were green.
//
// The fix: filter by `source.kind`, not by role alone.
test('REGRESSION: lastUserText picks the real question out of a 3-part batch', () => {
const extracted = lastUserText(realBatch('值班室分机号是多少'))
assert.equal(
extracted,
'值班室分机号是多少',
`must return the human question, not the injected context; got ${JSON.stringify(extracted)}`,
)
})
test('REGRESSION: auto-recall hits on a realistic batch (the bug was 0 hits)', async () => {
const { ctx, dir, done } = await app()
try {
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), topK: 3 })
await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' })
const decision = await ctx.waterfall(
'agent/pre-step',
{
agent: { id: 'a', session: {} },
messages: realBatch('值班室分机号是多少'),
turn: 1,
step: 1,
signal: new AbortController().signal,
},
() => Promise.resolve({ kind: 'enter', messages: realBatch('值班室分机号是多少') }),
)
assert.equal(decision.messages.length, 4, '3 batch messages + 1 injected recall')
const injected = decision.messages[3]
const text = injected.content.map((b) => b.text ?? '').join('')
assert.ok(
text.includes('4324'),
`recall must find the note; injected was ${JSON.stringify(text.slice(0, 100))}`,
)
} finally {
await done()
}
})
test('REGRESSION mutation: falling back to "last role=user" must drop recall to 0', async () => {
const { ctx, dir, done } = await app()
try {
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), topK: 3 })
await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' })
// The mutation: reintroduce the old extraction rule.
const oldRule = (messages) => {
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i]?.role !== 'user') continue
const t = (messages[i].content ?? [])
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
.map((b) => b.text)
.join('\n')
.trim()
if (t !== '') return t
}
return ''
}
const batch = realBatch('值班室分机号是多少')
const mutatedQuery = oldRule(batch)
// `recall` became async in phase B group A. Await it: a bare Promise has no
// `.length`, and `undefined < undefined` is false, so this assertion would
// silently pass while measuring nothing.
const mutatedHits = await mem.recall(mutatedQuery)
const realHits = await mem.recall(lastUserText(batch))
// Numbers on the record: the old rule must lose every hit.
assert.ok(
mutatedHits.length < realHits.length,
`mutation must lose hits: old-rule=${mutatedHits.length}, fixed=${realHits.length}`,
)
assert.equal(mutatedHits.length, 0, `old rule must score 0 hits, got ${mutatedHits.length}`)
assert.ok(realHits.length >= 1, 'the fixed rule must find at least one')
} finally {
await done()
}
})

View File

@ -0,0 +1,306 @@
/**
* D2 · 每一次丢弃都必须可计数。
*
* ## 病
*
* 一条候选可以从三个地方消失,而在本次改动之前只有前两个(空间过滤、宽度
* 不符)会被报告:
*
* · MinScore 门 —— 它自己的向量分不够
* · 结构性门 —— 既没有可用的向量分,也没有符号命中
* · topK 截断 —— 过了所有门,但排在调用方要的条数之外
*
* 后三个都是裸 `continue`,不进任何计数器。于是「6 条候选留下 1 条」与
* 「库里只有 1 条」在对外读数上完全一样:`skipped=0 skippedDim=0`。
* 实测(改动前):6 条块、minScore=0.99 → hits=0,两个计数都是 0。
*
* 运维看 `skipped=0` 会得出「没有任何东西被排除」的结论,而真相可能是阈值
* 把整批都挡了 —— 一个应该调阈值的信号被读成了「库是空的」。
*
* ## 判据形态
*
* 三条路径各造一个 fixture,断**各自**的计数。另加一条不变式:
* 候选池 = 命中 + 三个下游计数 + 上游两个计数,等式必须成立 —— 否则将来再
* 加一条静默丢弃路径,等式就会破。
*
* @module dsh-memory/test/recall-accounting
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { BlockStore } from '../lib/store.js'
import { recallBlocks, recallBlocksHybrid } from '../lib/recall.js'
const DIM = 8
const FP = 'd2:accounting'
const unit = (v) => { const n = Math.hypot(...v); return n === 0 ? v : v.map((x) => x / n) }
const axis = (i) => unit(Array.from({ length: DIM }, (_, k) => (k === i ? 1 : 0)))
/** 6 条块,各占一轴;query 落在轴 0,所以 cosine 已知且互不相同。 */
function seeded() {
const store = new BlockStore({ path: ':memory:' }).open()
const ids = []
for (let i = 0; i < 6; i += 1) {
ids.push(store.putBlock({ text: `块 ${i} 的正文`, vector: axis(i), fingerprint: FP }).id)
}
const query = unit([1, 0.5, 0.2, 0, 0, 0, 0, 0])
const scores = new Map(ids.map((id, i) => [id, i === 0 ? 0.95 : 0.1 + i * 0.01]))
return { store, ids, query, scores }
}
test('D2: candidates rejected by MinScore are counted, not silently dropped', () => {
const { store, query, scores } = seeded()
try {
const hits = recallBlocks(store, query, { topK: 6, fingerprint: FP, dimension: DIM, vectorScores: scores, minScore: 0.9 })
assert.equal(hits.length, 1, 'precondition: only the 0.95 candidate clears 0.9')
assert.equal(
hits.skippedScore,
5,
'D2: five candidates failed the score gate and must be REPORTED as such. '
+ 'Before this counter existed the result was indistinguishable from a '
+ 'one-block space, which sends the operator looking at the wrong subsystem.',
)
} finally {
store.close()
}
})
test('D2: candidates with neither vector score nor symbol hit are counted separately', () => {
const { store, ids, query } = seeded()
try {
// A candidate reaches the STRUCTURAL gate when it IS in the pool but has no
// usable vector score (absent, or <= 0) and no symbol match. A cosine of
// exactly 0 is the realistic trigger: it is what a candidate orthogonal to
// the query gets, and also what centring produces in a degenerate space.
const scores = new Map([[ids[0], 0.95], [ids[1], 0.8], [ids[2], 0]])
const hits = recallBlocks(store, 'zzz-no-symbol-match', {
topK: 6, fingerprint: FP, dimension: DIM, vectorScores: scores, minScore: 0.5,
})
assert.equal(hits.length, 2, 'precondition: only the two positive scores survive')
assert.equal(hits.skippedScore, 0, 'precondition: nothing failed the threshold')
assert.equal(
hits.skippedNoEvidence,
1,
'D2: a candidate that matched neither route must be counted under its own name — '
+ '"nothing to score" and "scored too low" have opposite remedies (re-embed vs. '
+ 'lower the threshold). Blocks that never entered the pool are a different, '
+ 'upstream fact and must not be folded in here.',
)
} finally {
store.close()
}
})
test('D2: candidates cut by the caller topK are counted as truncated', () => {
const { store, query, scores } = seeded()
try {
const all = recallBlocks(store, query, { topK: 6, fingerprint: FP, dimension: DIM, vectorScores: scores, minScore: 0.05 })
const top2 = recallBlocks(store, query, { topK: 2, fingerprint: FP, dimension: DIM, vectorScores: scores, minScore: 0.05 })
assert.equal(all.length, 6, 'precondition: all six clear a 0.05 threshold')
assert.equal(top2.length, 2, 'precondition: topK truncates')
assert.equal(top2.skippedScore, 0, 'nothing failed the score gate here')
assert.equal(
top2.truncated,
4,
'D2: the four that fell outside topK must be reported. Truncation is not a '
+ 'defect, but leaving it uncounted breaks the reconciliation of '
+ 'candidates = hits + counters.',
)
} finally {
store.close()
}
})
test('D2: hits plus every counter reconciles with the candidate pool', () => {
const { store, query, scores } = seeded()
try {
const hits = recallBlocks(store, query, { topK: 3, fingerprint: FP, dimension: DIM, vectorScores: scores, minScore: 0.15 })
const accounted = hits.length
+ hits.skippedScore
+ hits.skippedNoEvidence
+ hits.skippedUnscoreable
+ hits.truncated
+ hits.skipped
+ hits.skippedDim
assert.equal(
accounted,
6,
`D2: every candidate must land in exactly one bucket; ${accounted} of 6 accounted for. `
+ 'A shortfall means a new silent drop path was added without a counter.',
)
} finally {
store.close()
}
})
// ── D2 的第二个洞:在候选池存在之前就消失的块 ──────────────────────────────
//
// 07f8438 给三个「门」加了计数,但候选池是三处之外还有一段:`scoreVectorCandidates`
// 里算不出分数的行直接 continue —— 坏 JSON、宽度不符、非有限分量、零模,以及
// C4 之后新增的一种:**中心化把向量抹成尘埃**。这些块压根不进 vectorScores,
// 于是 recallBlocks 装配候选池时(`if (!symbolMatch && !vectorSource) continue`)
// 也看不到它们。
//
// 实测(3 条完全相同的块 + 中心化生效):
// hits=0 skipped=0 skippedDim=0 skippedScore=0 skippedNoEvidence=0 truncated=0
// —— 整个空间凭空消失,六个计数器全读 0。这正是 D2 自己禁止的静默分支,
// 只是位置比当初看的更深一层。
//
// 这条判据同时钉住两件事:计数非零,以及「命中 + 全部计数」仍能对上候选池。
test('D2: a vector that centring collapses is counted, not silently lost', async () => {
const DIM = 4
const FP = 'd2:unscoreable'
const unit = (v) => { const n = Math.hypot(...v); return n === 0 ? v : v.map((x) => x / n) }
const blockVec = unit([1, 0.2, 0.1, 0])
const queryVec = unit([1, 0.9, 0.1, 0])
const store = new BlockStore({ path: ':memory:' }).open()
try {
for (let i = 0; i < 3; i += 1) {
store.putBlock({ text: `重复块 ${i}`, vector: blockVec, fingerprint: FP })
}
const built = store.rebuildCentroid(FP)
assert.ok(built.meanLength > 0.999, `precondition: identical blocks give a unit mean; got ${built.meanLength}`)
assert.equal(built.isAnomalous, true, 'precondition: centring must actually engage')
const embedder = {
async embedText() { return queryVec },
info: () => ({ fingerprint: FP, dimension: DIM }),
}
const hits = await recallBlocksHybrid(store, embedder, 'zzz-no-symbol-match', { topK: 10, minScore: 0 })
assert.equal(hits.length, 0, 'precondition: every block is centred onto the mean and dropped')
assert.equal(
hits.skippedUnscoreable,
3,
'D2: these blocks were dropped before the candidate pool existed, so no other '
+ 'counter can see them. Reporting 0 here is what made a whole space vanish '
+ 'while the log read "skipped=0".',
)
const accounted = hits.length + hits.skipped + hits.skippedDim + hits.skippedScore
+ hits.skippedNoEvidence + hits.skippedUnscoreable + hits.truncated
assert.equal(
accounted,
3,
`D2: hits + every counter must equal the space's blocks; ${accounted} of 3.`,
)
} finally {
store.close()
}
})
// ── D1: 召回主路径不得因向量侧故障抛错 ──────────────────────────────────────
//
// 实测出来的真缺口:把「损坏向量」的处理从 skip 改成 throw 之后,
// phase-b-h 137 条、phase-b-a 38 条、recall-accounting 5 条 —— 145 条**全绿**。
// 也就是说「损坏的向量载荷不能把整轮召回打挂」这件事,此前没有任何判据守着。
//
// (父早期的覆盖率矩阵按条目 ID grep 测试名,把 C6/W1/F1 也标成零覆盖;实测
// 那些其实有判据,只是没按 ID 命名。D1 是四项里唯一真的零覆盖。)
//
// 决策 §0.4 的原话是「向量侧的故障不得让主路径抛错」:一块脏数据不能把整轮
// 记忆检索变成异常,模型那一侧看到的就是完全失忆。所以这里直接往库里塞一行
// 坏 payload,然后要求召回照常返回。
test('D1: a corrupt vector payload cannot take the recall path down', async () => {
const DIM = 4
const FP = 'd1:corrupt'
const unit = (v) => { const n = Math.hypot(...v); return n === 0 ? v : v.map((x) => x / n) }
const store = new BlockStore({ path: ':memory:' }).open()
try {
const healthy = store.putBlock({ text: '值班室的分机号是 4324', vector: unit([1, 0.1, 0, 0]), fingerprint: FP })
const broken = store.putBlock({ text: '一条被写坏向量的块', vector: unit([1, 0.2, 0, 0]), fingerprint: FP })
// ★ Two guards narrow what "corrupt" can even mean here, both measured:
// · the DDL enforces `json_valid(vector)` — a syntactically broken payload is
// refused outright (`ERR_SQLITE_ERROR: malformed JSON`), so it cannot reach
// the read path;
// · a trigger re-derives `vector_dim` from the payload, so a value of the wrong
// LENGTH is filtered out in SQL before scoring ever sees it.
// What survives both guards and still cannot be scored is an array of the right
// length whose elements are not numbers — exactly what a provider that switched
// its encoding (or a migration that stringified values) would leave behind.
store.raw().prepare('UPDATE memory_blocks SET vector = ? WHERE id = ?')
.run('["a","b","c","d"]', broken.id)
const embedder = {
async embedText() { return unit([1, 0.15, 0, 0]) },
info: () => ({ fingerprint: FP, dimension: DIM }),
}
let hits
try {
hits = await recallBlocksHybrid(store, embedder, '分机号是多少', { topK: 5 })
} catch (error) {
assert.fail(
'D1: a single corrupt vector payload threw out of the recall path '
+ `(${error.message}). Decision §0.4: the vector side failing must not take `
+ 'the main path down — one bad row would otherwise cost the whole turn its memory.',
)
}
assert.ok(
hits.some((hit) => hit.id === healthy.id),
'D1: the healthy block must still be recalled while a corrupt row sits next to it',
)
assert.equal(
hits.skippedUnscoreable,
1,
'D1/D2: the corrupt row is skipped AND counted — silent skipping is the other half '
+ 'of this defect.',
)
} finally {
store.close()
}
})
// ── 窄读等价:候选池的来源换了一条 SQL,但看到的东西必须一样 ─────────────────
//
// `recallBlocks` 装配候选池时要把「符号命中」和「向量候选」并起来,为此它要遍历
// 全表。原来用 `allBlocks(-1)`(`SELECT *`),实测在 8000 块 × 512 维时把那 79MB
// 向量文本整份物化了一遍 —— 而它只拿向量列判断「这块有没有向量」。
//
// 换成 `symbolCandidates()` 后,8000 块的召回从 624.0ms 降到 409.6ms(-34%)。
// 性能改动最容易悄悄改变语义,所以这里钉住等价本身:
// 1. 行集与顺序完全一致(同一条 ORDER BY)
// 2. 每一条的 has_vector 与旧谓词 `String(vector ?? '') !== ''` 逐条相同
//
// ★ 有一个已知例外,不在这里断言:字面量字符串 'null' 在旧谓词下算「有向量」,
// 而 `vectorCandidates` 把它当「没有」。这个分歧早于本次改动,不在本判据里
// 悄悄改掉 —— 改它就是另一件事,要有自己的判据。
test('narrow read: symbolCandidates() sees exactly what allBlocks(-1) saw', () => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
const fp = 'narrow:equiv'
const withVector = store.putBlock({ text: '有向量的块 4324', vector: [1, 0, 0, 0], fingerprint: fp })
const noVector = store.putBlock({ text: '没有向量的块 4324' })
const emptyVector = store.putBlock({ text: '向量为空串的块 4324', vector: [0.5, 0.5, 0, 0], fingerprint: fp })
store.raw().prepare("UPDATE memory_blocks SET vector = '' WHERE id = ?").run(emptyVector.id)
const wide = store.allBlocks(-1)
const narrow = store.symbolCandidates()
assert.deepEqual(
narrow.map((row) => row.id),
wide.map((row) => row.id),
'the narrow read must return the same rows in the same order — the pool is a Map, '
+ 'and insertion order is what the caller iterates',
)
const wideById = new Map(wide.map((row) => [row.id, row]))
for (const row of narrow) {
const old = wideById.get(row.id)
assert.equal(
row.has_vector,
String(old.vector ?? '') !== '' ? 1 : 0,
`has_vector must reproduce the predicate it replaced, for block ${row.id}`,
)
}
assert.equal(narrow.find((r) => r.id === withVector.id).has_vector, 1, 'a real vector counts')
assert.equal(narrow.find((r) => r.id === noVector.id).has_vector, 0, 'no vector does not')
assert.equal(narrow.find((r) => r.id === emptyVector.id).has_vector, 0, 'an empty payload does not')
} finally {
store.close()
}
})

201
test/store.test.js Normal file
View File

@ -0,0 +1,201 @@
/**
* Unit tests for dsh-memory store + recall (phase A).
*
* Complements `scripts/checkpoints.js`: the checkpoints prove the harness wiring
* (real cordis, real SystemPrompt/ToolRuntime); these prove the algorithm and
* carry the mutation assertions that must be able to turn red.
*
* Run: node --test test/
*
* @module dsh-memory/test/store
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { BlockStore, blockId } from '../lib/store.js'
import {
buildMatchExpression,
cjkNeedles,
isNumericSymbol,
querySymbols,
recallBlocks,
renderRecall,
} from '../lib/recall.js'
/** @returns {{store: BlockStore, done: () => void}} */
function tempStore() {
const dir = mkdtempSync(join(tmpdir(), 'dsh-memory-test-'))
const store = new BlockStore({ path: join(dir, 'x.db') }).open()
return {
store,
done: () => {
store.close()
rmSync(dir, { recursive: true, force: true })
},
}
}
test('blockId is content-addressed and stable', () => {
assert.equal(blockId('值班室分机号 4324'), blockId('值班室分机号 4324'))
assert.notEqual(blockId('a'), blockId('b'))
assert.match(blockId('x'), /^blk_[0-9a-f]{12}$/)
})
test('putBlock is idempotent and keeps existing optional columns', () => {
const { store, done } = tempStore()
try {
const first = store.putBlock({ text: 'a note', source: 'qq', scene: 'chan:qq', semanticType: 'Fact' })
assert.equal(first.created, true)
// Re-commit with no optional fields: text updates, metadata must NOT be blanked.
const second = store.putBlock({ text: 'a note' })
assert.equal(second.created, false)
assert.equal(second.id, first.id)
const row = store.getBlock(first.id)
assert.equal(row.source, 'qq', 'source must survive a bare re-commit')
assert.equal(row.scene, 'chan:qq', 'scene must survive a bare re-commit')
assert.equal(row.semantic_type, 'Fact', 'semantic_type must survive a bare re-commit')
assert.equal(store.countBlocks(), 1)
} finally {
done()
}
})
test('edges are unique per (source,target,type)', () => {
const { store, done } = tempStore()
try {
const a = store.putBlock({ text: '值班室分机号 4324' }).id
const b = store.putBlock({ text: '4324 已停用' }).id
const edge = { sourceKind: 'block', sourceId: a, targetKind: 'block', targetId: b, type: 'superseded_by' }
assert.equal(store.addEdge(edge).created, true)
assert.equal(store.addEdge(edge).created, false, 'duplicate edge must not double')
assert.equal(store.countEdges(), 1)
} finally {
done()
}
})
test('querySymbols mirrors HologramAgent regexes and drops single chars', () => {
assert.deepEqual(querySymbols('本机 13010 端口'), ['13010'])
assert.deepEqual(querySymbols('版本 v2.31.5'), ['v2.31.5'])
assert.deepEqual(querySymbols('billing0 端口'), ['billing0'], 'no bare 0 from billing0')
// Single-character symbols are rejected (abstain.go:115 "单字不成符号").
assert.deepEqual(querySymbols('a 1 中'), [])
})
test('isNumericSymbol identifies digit-bearing symbols', () => {
assert.equal(isNumericSymbol('13010'), true)
assert.equal(isNumericSymbol('v2.31.5'), true)
assert.equal(isNumericSymbol('billing'), false)
})
test('buildMatchExpression quotes terms and refuses an empty query', () => {
assert.equal(buildMatchExpression(''), null)
assert.equal(buildMatchExpression(' '), null)
const expr = buildMatchExpression('billing 端口')
assert.ok(expr.includes('"billing"'), expr)
// Injected FTS5 operators must be neutralised, never passed through.
const hostile = buildMatchExpression('a OR b NEAR(c)')
assert.ok(!/\bNEAR\(/.test(hostile ?? ''), `FTS operators must be quoted, got ${hostile}`)
})
test('cjkNeedles yields 2..4-char windows plus the whole run', () => {
const needles = cjkNeedles('值班室分机号')
assert.ok(needles.includes('值班室分机号'), 'whole run present')
assert.ok(needles.includes('分机号'), '3-char window present')
assert.ok(needles.includes('分机'), '2-char window present')
assert.ok(!needles.includes('值'), 'single chars excluded')
})
test('exact numeric hit pins to the top above plain lexical matches', () => {
const { store, done } = tempStore()
try {
store.putBlock({ text: '值班室分机号 4379 已停用' })
store.putBlock({ text: '值班室分机号 4324,值班人 老周' })
const hits = recallBlocks(store, '4324 是多少', { topK: 5 })
assert.equal(hits[0].text.includes('4324'), true, `expected 4324 first, got ${hits[0].text}`)
assert.equal(hits[0].exact, true)
} finally {
done()
}
})
test('a matched word prefers the block where it is not a prefix of a longer word', () => {
const { store, done } = tempStore()
try {
store.putBlock({ text: 'billing2服务 端口=8501' })
store.putBlock({ text: 'billing服务 端口=8801' })
const hits = recallBlocks(store, 'billing 服务的端口是多少', { topK: 2 })
assert.equal(
hits[0].text.includes('8801'),
true,
`expected the exact "billing" note first, got ${hits[0].text}`,
)
} finally {
done()
}
})
test('unrelated query yields nothing (no silent everything-match)', () => {
const { store, done } = tempStore()
try {
store.putBlock({ text: '值班室分机号 4324' })
store.putBlock({ text: 'billing服务 端口=8499' })
assert.deepEqual(recallBlocks(store, 'zzz-不存在-qqq'), [])
} finally {
done()
}
})
test('renderRecall states the content is historical and yields "" for no hits', () => {
assert.equal(renderRecall([]), '')
const text = renderRecall([{ text: '值班室分机号 4324', exact: true, score: 1.01 }])
assert.ok(text.includes('PAST notes'), 'the model must be told these are historical')
assert.ok(text.includes('4324'))
})
// ── mutations: each must turn a specific assertion red ────────────────────
test('MUTATION: removing the numeric-gate makes a bare 0 pin noise', () => {
const { store, done } = tempStore()
try {
store.putBlock({ text: '发布批次 第8批|版本=v2.38.0' })
store.putBlock({ text: 'billing0服务 端口=8499' })
// Real behaviour: `billing0` is the symbol, so the billing note wins.
const real = recallBlocks(store, 'billing0 端口是多少', { topK: 1 })
assert.equal(real[0].text.includes('8499'), true, `expected billing note, got ${real[0].text}`)
// Mutation: pretend a bare `0` were a symbol — it appears in v2.38.0, so the
// unrelated note would pin to the top. Demonstrated directly:
const bare = querySymbols('billing0')
assert.ok(!bare.includes('0'), 'the single-char gate is what prevents this')
} finally {
done()
}
})
test('MUTATION: containment-only recall loses discrimination (the reason FTS is primary)', () => {
const { store, done } = tempStore()
try {
store.putBlock({ text: '值班室分机号 4324' })
store.putBlock({ text: '值班室分机号 4379 已停用' })
// Primary path: precise. One hit for an exact query.
assert.equal(recallBlocks(store, '4324', { topK: 5 }).length, 1)
// Mutated path: a shared CJK prefix matches both, so the answer becomes
// ambiguous. This is why FTS + symbol pin, not LIKE, is the primary path.
const contained = store.allBlocks(100).filter((row) => row.text_content.includes('值班室'))
assert.equal(contained.length, 2)
} finally {
done()
}
})
test('close is idempotent and a closed store refuses work', () => {
const { store, done } = tempStore()
store.putBlock({ text: 'x' })
store.close()
store.close() // must not throw
assert.equal(store.isOpen, false)
assert.throws(() => store.countBlocks(), /not open/)
done()
})