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.
This commit is contained in:
2026-11-05 11:54:12 +08:00
parent 0bc160b1bd
commit 44f883745f
2 changed files with 85 additions and 0 deletions

View File

@ -836,6 +836,22 @@ export class StableMem extends Service {
states,
...this.store.vectorStats(space?.fingerprint ?? ''),
centroid: space === null ? null : (this.store.loadCentroid(space.fingerprint) ?? null),
// ★ C5's `reason` needs an outlet, and this is it.
//
// `centroid` above is the raw row (`loadCentroid`), which says WHAT the
// centre is but not WHY centring is or is not happening. `reason` is the
// answer to the second question — no_centroid / dim_mismatch / zero_sample /
// stale / isotropic / ok — and until now it was computed and thrown away:
// nothing under lib/ read the value `centroidStatus` returns.
//
// ★ `schedule: () => {}` is load-bearing. `centroidStatus` REGISTERS a
// rebuild when it judges a space stale; a stats read must not mutate
// anything. With the no-op scheduler, `rebuildScheduled` is always false
// here, which is exactly what a pure read should report.
centroid_state: space === null ? null : this.store.centroidStatus(space.fingerprint, {
queryDim: space.dimension,
schedule: () => {},
}),
// Per-call, not library state: NULL until a recall has happened.
last_recall: this.lastRecall,
}

View File

@ -191,3 +191,72 @@ test('C5: centroidRecallState keeps its stale:null projection but carries the re
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 })
}
})