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.
This commit is contained in:
2026-11-05 11:32:49 +08:00
parent 5cb97edf39
commit 28866ac6e6
3 changed files with 212 additions and 5 deletions

View File

@ -641,9 +641,12 @@ export class BlockStore {
* transaction") and throws SQLITE_BUSY when a statement is still iterating. So
* this method only REGISTERS a rebuild and returns "no centering this round".
*
* ★ C3 gives facts only. Distinguishing WHICH reason disabled centering (no
* centroid / dimension mismatch / stale / isotropic) is C5 and is NOT covered
* here.
* ★ C5: every branch now names WHY centring was disabled, in `reason`.
* Without it the four causes were only inferable from field combinations —
* `no_centroid` and `dim_mismatch` both report `stale: null`, and `isotropic`
* is indistinguishable from a healthy space except by `available`. That is
* fine for a caller that only asks "may I centre?", and useless for an operator
* asking "why is semantic recall not correcting?".
*
* @param {string} fingerprint
* @param {{queryDim?: number, now?: () => number,
@ -652,7 +655,12 @@ export class BlockStore {
* against the real branch logic instead of a stubbed global timer.
* @returns {{available: boolean, stale: boolean|null, dim: number|null,
* vectorCount: number|null, currentBlocks: number, delta: number|null,
* rebuildScheduled: boolean}}
* rebuildScheduled: boolean, reason: CentroidReason}}
*
* `reason` is one of `ok` / `no_centroid` / `dim_mismatch` / `zero_sample` /
* `stale` / `isotropic`. `ok` is the only one that coincides with
* `available: true`; `zero_sample` is a fifth cause that exists in the Go
* branch (`centroid_store.go:179-185`) even though the plan only names four.
* @throws when the stored centroid metadata is corrupt (`dim <= 0`,
* internal/memory/centroid_store.go:165-167)
*/
@ -668,6 +676,7 @@ export class BlockStore {
return {
available: false, stale: null, dim: null, vectorCount: null,
currentBlocks, delta: null, rebuildScheduled: false,
reason: 'no_centroid',
}
}
@ -683,6 +692,7 @@ export class BlockStore {
return {
available: false, stale: null, dim, vectorCount, currentBlocks,
delta: null, rebuildScheduled: false,
reason: 'dim_mismatch',
}
}
@ -697,6 +707,7 @@ export class BlockStore {
available: false,
stale: currentBlocks > 0,
dim, vectorCount, currentBlocks, delta, rebuildScheduled: false,
reason: 'zero_sample',
}
}
@ -705,6 +716,7 @@ export class BlockStore {
const rebuildScheduled = this.#scheduleCentroidRebuild(fingerprint, { now, schedule })
return {
available: false, stale: true, dim, vectorCount, currentBlocks, delta, rebuildScheduled,
reason: 'stale',
}
}
@ -713,11 +725,13 @@ export class BlockStore {
// :205-208 — built as isotropic: centering is not enabled for this space.
return {
available: false, stale: false, dim, vectorCount, currentBlocks, delta, rebuildScheduled: false,
reason: 'isotropic',
}
}
return {
available: true, stale: false, dim, vectorCount, currentBlocks, delta, rebuildScheduled: false,
reason: 'ok',
}
}

View File

@ -18,7 +18,7 @@
"node": ">=22.5.0"
},
"scripts": {
"test": "node --test test/store.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/memory-log.test.js test/loop.test.js test/loop-ports.test.js test/loop-system-e2e.test.js",
"test": "node --test test/store.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/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": {

View File

@ -0,0 +1,193 @@
/**
* 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()
}
})