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:
16
lib/index.js
16
lib/index.js
@ -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,
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user