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:
22
lib/store.js
22
lib/store.js
@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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": {
|
||||
|
||||
193
test/phase-b-c5-reasons.test.js
Normal file
193
test/phase-b-c5-reasons.test.js
Normal 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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user