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* 零改动。
This commit is contained in:
41
lib/index.js
41
lib/index.js
@ -371,6 +371,9 @@ export class StableMem extends Service {
|
||||
...input,
|
||||
expectedDimension: this.space()?.dimension,
|
||||
})
|
||||
if (typeof input.fingerprint === 'string' && input.fingerprint !== '') {
|
||||
this.#ensureCentroid(input.fingerprint)
|
||||
}
|
||||
return { ...written, embedded: true }
|
||||
}
|
||||
|
||||
@ -409,9 +412,47 @@ export class StableMem extends Service {
|
||||
// ★ From the space object, never re-derived and never from input.
|
||||
fingerprint: space.fingerprint,
|
||||
})
|
||||
// ★ C4 的另一半:中心必须存在,否则去均值永远不会发生。
|
||||
this.#ensureCentroid(space.fingerprint)
|
||||
return { ...written, embedded: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保一个向量空间有中心,没有就建一个。
|
||||
*
|
||||
* ★ 为什么这件事必须写在写入侧
|
||||
* 在 C4 之前,`graph_centroid` 在生产上永远是空表:`saveSpaceCentroid` 只有
|
||||
* 测试在调,`centroidStatus` / `centroidRecallState` 在 `lib/` 里没有调用者,
|
||||
* `rebuildCentroid` 也只有测试在调。于是 `loadCentroid` 永远返回 undefined、
|
||||
* `isAnomalous` 永远是 false、去均值一次也不会发生 —— 而判据会绿,
|
||||
* 因为判据自己会先手工 `rebuildCentroid`。那是本轮反复抓的「代码写了没接线」。
|
||||
*
|
||||
* ★ 为什么不能放在召回侧
|
||||
* `rebuildCentroid` 是 O(n·d)(实测 dim=512、2000 块 85.5ms),而召回每轮
|
||||
* pre-step 都跑。C3-k 也明令 `countVectored` 不得进热路径,而 `centroidStatus`
|
||||
* 第一行就是它。所以:召回侧只 `loadCentroid` 纯读,重建留在这里。
|
||||
*
|
||||
* ★ 失败必须吞掉
|
||||
* 一次中心重建失败不能把写入变成失败。下一次 commit 会再试;在那之前
|
||||
* 召回拿不到中心就走未校正路径,结果仍是对的,只是没校正。
|
||||
* 这与 D1(向量侧故障不得让主路径抛错)是同一条纪律。
|
||||
*
|
||||
* ★ 只在「没有中心行」时建
|
||||
* 有中心行就交给已有的 stale 判定去管(块数漂移超 20% 时重建),
|
||||
* 不在这里每写一条就算一遍全空间。首次建的代价很小:那时块还少。
|
||||
*
|
||||
* @param {string} fingerprint
|
||||
*/
|
||||
#ensureCentroid(fingerprint) {
|
||||
if (typeof fingerprint !== 'string' || fingerprint === '') return
|
||||
try {
|
||||
if (this.store.loadCentroid(fingerprint) !== undefined) return
|
||||
this.store.rebuildCentroid(fingerprint)
|
||||
} catch {
|
||||
// 吞掉:写入已经成功,中心可以下次再建。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the centroid for the configured space.
|
||||
*
|
||||
|
||||
@ -803,8 +803,33 @@ function scoreVectorCandidates(store, queryVector, { fingerprint, dimension } =
|
||||
const rows = store.vectorCandidates(fingerprint, dimension)
|
||||
if (rows.length === 0) return scores
|
||||
|
||||
// ── C4 · 中心化校正 ────────────────────────────────────────────────────
|
||||
//
|
||||
// 一次索引查询取本空间的中心,仅当它被判定为各向异性、且宽度与查询一致
|
||||
// 时才启用。维度不符时**不校正也不报错**:按错误维度截断或补齐造出来的
|
||||
// 余弦,看起来像分数,实际没有意义。
|
||||
//
|
||||
// 门控条件照 C2:`isAnomalous === false` 的空间不校正。这不是优化开关。
|
||||
// 各向同性空间的均值范数本来就低,减掉它只会放大噪声 —— 实测 8 块正交
|
||||
// (mean norm 0.3536)在无条件校正下命中数 8 → 1,且因为 V5 的结构性门
|
||||
// (vectorScore <= 0 且 symbolHit === 0 直接丢弃)无法靠 minScore 调参救回。
|
||||
// 所以这条 if 是正确性的一部分,不是性能的一部分。
|
||||
//
|
||||
// ★ loadCentroid 是纯读的一次索引查询,不进写路径、不触发重建。
|
||||
// 重建挂在写入侧(lib/index.js 的 commit 之后),因为它是 O(n·d),
|
||||
// 放进每轮 pre-step 就是灾难;C3-k 也明令计数查询不得进热路径。
|
||||
const centroid = store.loadCentroid(fingerprint)
|
||||
const mean = centroid?.isAnomalous === true && centroid.dim === queryVector.length
|
||||
? centroid.mean
|
||||
: null
|
||||
const centeredQuery = mean === null ? null : centerVector(queryVector, mean)
|
||||
// `mean !== null` 但 `centeredQuery === null` 表示减完是零向量(没有方向),
|
||||
// 同样退回未校正路径,而不是拿零向量去算余弦。
|
||||
const centering = centeredQuery !== null
|
||||
|
||||
const scoringQuery = centering ? centeredQuery : queryVector
|
||||
let queryNorm = 0
|
||||
for (const value of queryVector) queryNorm += value * value
|
||||
for (const value of scoringQuery) queryNorm += value * value
|
||||
queryNorm = Math.sqrt(queryNorm)
|
||||
if (!(queryNorm > 0)) return scores
|
||||
|
||||
@ -817,10 +842,16 @@ function scoreVectorCandidates(store, queryVector, { fingerprint, dimension } =
|
||||
}
|
||||
if (!Array.isArray(vector) || vector.length !== queryVector.length) continue
|
||||
|
||||
// ★ 两边都中心化。只处理一边 = 一边带公共分量、另一边不带,算出来的
|
||||
// 余弦是两种量纲的混合(HA centroid_store.go:439-442 的原话)。
|
||||
// 实测:只减 query 时同值对的余弦从 1.0 掉到 0.3787 —— 那就不是夹角了。
|
||||
const scoringVector = centering ? centerVector(vector, mean) : vector
|
||||
if (scoringVector === null) continue
|
||||
|
||||
let dot = 0
|
||||
let norm = 0
|
||||
for (let index = 0; index < vector.length; index += 1) {
|
||||
const value = Number(vector[index])
|
||||
for (let index = 0; index < scoringVector.length; index += 1) {
|
||||
const value = Number(scoringVector[index])
|
||||
// A non-numeric element makes the whole score meaningless. Skip the row
|
||||
// instead of letting NaN propagate into the sort, where it would silently
|
||||
// randomise the order (NaN comparisons are always false).
|
||||
@ -828,7 +859,7 @@ function scoreVectorCandidates(store, queryVector, { fingerprint, dimension } =
|
||||
dot = Number.NaN
|
||||
break
|
||||
}
|
||||
dot += value * queryVector[index]
|
||||
dot += value * scoringQuery[index]
|
||||
norm += value * value
|
||||
}
|
||||
if (!Number.isFinite(dot) || !(norm > 0)) continue
|
||||
@ -837,6 +868,39 @@ function scoreVectorCandidates(store, queryVector, { fingerprint, dimension } =
|
||||
return scores
|
||||
}
|
||||
|
||||
/**
|
||||
* 减均值再 L2 归一化(HA `Centroid.CenterVector`,`centroid.go:185-188`)。
|
||||
*
|
||||
* ★ 归一化不能省。减去均值会改变模长,不归一化就是拿两把不同的尺子量。
|
||||
* 实测:同一对相同向量,两侧都归一化时余弦恒为 1;只减不归一时它会变成 0.61
|
||||
* —— 看起来仍是个「分数」,但已经不是在算夹角了。
|
||||
*
|
||||
* ★ 零向量返回 `null`。减完均值恰好没有方向(向量就躺在中心上)时,
|
||||
* 余弦无定义。调用方必须跳过这一行,而不是拿零向量去算出一个 0 分
|
||||
* 再让它参与排序 —— 0 分在 V5 的门里等于「未命中」,会把一次真实的
|
||||
* 无法比较伪装成一次「不相关」。
|
||||
*
|
||||
* @param {number[]} vector
|
||||
* @param {number[]} mean the space centroid mean, same length
|
||||
* @returns {number[]|null} centered unit vector, or null when it has no direction
|
||||
*/
|
||||
function centerVector(vector, mean) {
|
||||
const out = new Array(vector.length)
|
||||
let norm = 0
|
||||
for (let index = 0; index < vector.length; index += 1) {
|
||||
const delta = Number(vector[index]) - mean[index]
|
||||
// A non-numeric component poisons the whole direction: bail out rather than
|
||||
// let NaN reach the sort (NaN comparisons are always false ⇒ silent shuffle).
|
||||
if (!Number.isFinite(delta)) return null
|
||||
out[index] = delta
|
||||
norm += delta * delta
|
||||
}
|
||||
norm = Math.sqrt(norm)
|
||||
if (!(norm > 0)) return null
|
||||
for (let index = 0; index < out.length; index += 1) out[index] /= norm
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Render recall hits as the model-facing text injected into the turn.
|
||||
*
|
||||
|
||||
@ -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/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/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": {
|
||||
|
||||
283
test/phase-b-c4-centering.test.js
Normal file
283
test/phase-b-c4-centering.test.js
Normal file
@ -0,0 +1,283 @@
|
||||
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.38 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`)
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
// 端到端:同一个库,召回必须已经用上校正
|
||||
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 })
|
||||
}
|
||||
})
|
||||
@ -744,6 +744,20 @@ test('ticket②-strong mutation: re-deriving in the centroid path must break the
|
||||
})
|
||||
await mem.commit({ text: 'a' })
|
||||
|
||||
// ★ C4 让 commit 为本空间自动建中心(写入侧 #ensureCentroid),
|
||||
// 而这个用例的前提是「本空间的 key 上没有中心」。两者不矛盾:
|
||||
// 它验证的是「用另一个拼写推导出的 key 查不到中心」,所以先把本空间
|
||||
// 那一行显式清掉,让 miss 重新成为一个可观测的事实。
|
||||
// 没有这一步,下面的 assert 会因为「本来就有一个中心」而失败,
|
||||
// 红的原因跟它要守的约束无关。
|
||||
mem.store.raw().prepare('DELETE FROM graph_centroid WHERE fingerprint = ?')
|
||||
.run(mem.space().fingerprint)
|
||||
assert.equal(
|
||||
mem.store.loadCentroid(mem.space().fingerprint),
|
||||
undefined,
|
||||
'precondition: the block-side key must carry no centroid before the mutation',
|
||||
)
|
||||
|
||||
// Mutation: the centroid path derives its own key from a *different*
|
||||
// spelling that normalisation does NOT collapse — a different query
|
||||
// string is a genuinely different endpoint (this slice deliberately does
|
||||
|
||||
Reference in New Issue
Block a user