五条全部处理。第 5 条确认属实:saveSpaceCentroid 在 lib/ 里零调用点,
真实使用下中心表永久空着,票③ 的「dim unknown」会从边角变成常态。
W1/W5 信号转发(最硬的一条)
- execute(args, exec) → commit(input, {signal}) → embedText(text, signal)
- abort 时按「向量化失败」降级落盘:块写入、vector 留空
- 依据 dsh-tools/lib/types/index.d.ts:120-122
★ 一处我判错了,据实记录
W5 我原本断言「abort 时 isError === false」,实测是 true。查了实现:
dsh-tools/lib/index.js:3408 在工具成功后**再检查一次 signal**,
已 abort 就用 toolAbortedResult 覆盖成功结果。所以 isError 不归本插件控制,
断言它等于和 harness 对抗。改为断言**副作用**(块已写入、vector 为空),
并额外断言 error.info.code === 'ABORTED'。
W2 九个调用点补 await
scripts/checkpoints.js ×4、test/plugin.test.js ×5、test/loader.test.js ×2
(loader 两处保持并发语义用 Promise.all)。
原本绿是因为「无 embedder 时第一个 await 之前就写完 putBlock」——
这个隐式事实一旦被一行 await 打破,九处会静默变成「块没写」,
而且红会出现在离原因很远的地方(靠后面能否召回间接断言)。
W3 空指纹先判后嵌
原来顺序是 await embedText → putBlock 抛 → catch → 再 putBlock,
语义终点对但白付一次网络往返,且与 recall.js:430 的口径不一致。
改为 embed 之前判 space.fingerprint === ''。
W4 写侧维度校验
commit 现在检查 vector.length === space.dimension,不符按 embed failure 处理
(无向量落盘)。不落坏向量的理由:召回侧会按 skippedDim 静默跳过,
用户看到的是「记忆里有这条,但永远召不回来」。
W8 建中心入口(第 5 条)
新增 store.rebuildCentroid(fingerprint),移植 RebuildCentroid
(centroid_store.go:246-320):
· 只取该空间的带向量行
· 维度不符跳过不强制
· 按 L2 归一化后累加均值
· 均值范数 >= 0.6 才判 anomalous(centroid.go:87)
· 计入 stats:meanLength / avgPairCosine / skippedRows
并新增 MEAN_THRESHOLD 常量。
★ W8 第一版判据是错的:我用完全共线的向量(meanLength = 1.0000)
去测 0.6 阈值,把阈值改成 0.95 仍然通过。已改用 ±35° 锥
(meanLength ≈ 0.879,落在 0.6 与 0.95 之间),并加断言
「语料必须落在两阈值之间」,否则测试自身就失去区分力。
判据与变异(全部在 /tmp 干净副本上跑)
W4 维度不符 → 块在、vector=''、fingerprint='' | 删维度检查 → 红
W5 abort → 块在、vector=''、elapsed<2s | 不传 signal → 红(实测 5003ms)
W6 无 embedder 时 {id,created} 与 putBlock 同形 | —
W7 空指纹 → embedText 调用 0 次 | 改为事后判 → 红
W8 同空间重建、跨空间隔离、坏维度跳过、阈值判定 | 去空间过滤 → 3 条红
| 去维度过滤 → 1 条红
| 阈值 0.6→0.95 → 1 条红
未越界
- 四态计数(noVector/unknownSpace/spaceMismatch/embedFailure)留给 F/D
- recallBlocks 仍然同步、未接入 vectorScores(V 组的事)
- 票③「dim 来自中心元数据而非配置」继续有效;saveSpaceCentroid 仍在用
dim ?? space.dimension,已在注释里标记为 C 组要区分「没建过」与
「建过但维度取自配置」——声明不是证据
顺序按你的调整记下:H(完工) → V → C → F → D。
C 组准入条件已满足一半:机制(rebuildCentroid)有了,
「谁触发建中心」待 C 组定。
验证
- npm test 99/99(90 + W4/W5/W6/W7 六条 + W8 三条)
- npm run check 10/10
- 提交前连跑 3 次 test + 3 次 check,6 次全绿
682 lines
27 KiB
JavaScript
682 lines
27 KiB
JavaScript
/**
|
|
* SQLite storage for block memory: the three tables required by the MVP slice.
|
|
*
|
|
* Mirrors HologramAgent's `memory_blocks` / `memory_block_edges` / `graph_centroid`
|
|
* (internal/memory/graph.go:2577, :320, centroid_store.go:58), restricted to the
|
|
* text modality — media blocks are deliberately out of scope for this slice
|
|
* (docs/feasibility.md §5.4: dsh attachment is CAS, which conflicts with
|
|
* HologramAgent's "bytes live inside the block" design).
|
|
*
|
|
* Why node:sqlite instead of a package: it is built in (Node >= 22.5), it already
|
|
* ships FTS5, and dsh itself uses it (dsh-session-query-sqlite/lib/types/schema.d.ts:2).
|
|
* `node --experimental-sqlite` is NOT required on Node 22.23 — the module is
|
|
* available behind an ExperimentalWarning only.
|
|
*
|
|
* @module dsh-memory/store
|
|
*/
|
|
|
|
import { createHash } from 'node:crypto'
|
|
import { mkdirSync } from 'node:fs'
|
|
import { dirname, resolve } from 'node:path'
|
|
import { DatabaseSync } from 'node:sqlite'
|
|
|
|
/** Modality union, mirroring `memory.BlockModality` (internal/memory/block.go:12).
|
|
* Only `text` is produced by this slice; the enum keeps the column honest. */
|
|
export const MODALITY_TEXT = 'text'
|
|
|
|
/** The three tables. Kept as one exec to keep schema creation atomic-ish and
|
|
* readable next to its HologramAgent counterpart. */
|
|
/**
|
|
* Current schema version, stamped into `PRAGMA user_version`.
|
|
*
|
|
* 0 phase A: memory_blocks without vector/fingerprint
|
|
* 1 phase B: + memory_blocks.vector, + memory_blocks.fingerprint,
|
|
* graph_centroid aligned with HA + is_anomalous column
|
|
*
|
|
* ★ Why a version and not just ALTER-and-hope:
|
|
* every phase-A statement is `CREATE TABLE IF NOT EXISTS`, which is a **silent
|
|
* no-op** on an existing table. An old database therefore opens without error,
|
|
* recalls fine through FTS, and throws on the FIRST commit
|
|
* (`table memory_blocks has no column named vector`). The store becomes
|
|
* read-only with no diagnostic at load time — the worst shape a failure can
|
|
* take. The version makes the upgrade explicit and testable.
|
|
*/
|
|
export const SCHEMA_VERSION = 1
|
|
|
|
/**
|
|
* Bring an existing database up to {@link SCHEMA_VERSION}.
|
|
*
|
|
* Probes the actual column list rather than guessing or try/catching: a
|
|
* `user_version` of 0 is not proof that the columns are absent (a database
|
|
* created by a phase-B build that crashed before stamping would look
|
|
* version-0 while already having them), and relying on ALTER throwing is how
|
|
* half-applied migrations become invisible.
|
|
*
|
|
* @param {import('node:sqlite').DatabaseSync} db an open handle
|
|
* @returns {number} the version after migrating
|
|
*/
|
|
function migrate(db) {
|
|
let version = db.prepare('PRAGMA user_version').get().user_version
|
|
|
|
if (version < 1) {
|
|
const existing = new Set(
|
|
db.prepare(`SELECT name FROM pragma_table_info('memory_blocks')`).all().map((row) => row.name),
|
|
)
|
|
if (existing.size === 0) {
|
|
// No table at all: DDL below creates it at the current shape. Nothing to
|
|
// migrate, and stamping the version is still correct.
|
|
version = SCHEMA_VERSION
|
|
} else {
|
|
// SQLite requires a non-NULL default when adding a NOT NULL column to a
|
|
// populated table; '' is the same "not embedded yet" sentinel putBlock uses.
|
|
if (!existing.has('vector')) {
|
|
db.exec("ALTER TABLE memory_blocks ADD COLUMN vector TEXT NOT NULL DEFAULT ''")
|
|
}
|
|
if (!existing.has('fingerprint')) {
|
|
db.exec("ALTER TABLE memory_blocks ADD COLUMN fingerprint TEXT NOT NULL DEFAULT ''")
|
|
}
|
|
version = 1
|
|
}
|
|
}
|
|
|
|
// graph_centroid changed shape between phase A
|
|
// (fingerprint, dim, count, sum_json, updated_at) and phase B
|
|
// (fingerprint, dim, vector_count, vector, stats, is_anomalous, built_at).
|
|
//
|
|
// ★ The rebuild MUST happen here, not via the DDL above. DDL already ran, so on
|
|
// a pre-existing database its `CREATE TABLE IF NOT EXISTS` was a no-op; if the
|
|
// old table is dropped without rebuilding, the store opens cleanly, stamps
|
|
// user_version=1, accepts putBlock — and throws `no such table` on the first
|
|
// saveCentroid. That is the narrowest possible failure window, which is why
|
|
// MIG1 (memory_blocks only) could not see it. MIG3 pins it.
|
|
//
|
|
// ★ Why a crash between RENAME and the version stamp is NOT a problem — and
|
|
// the reason is NOT the DDL ordering (I initially wrote that, then checked it):
|
|
//
|
|
// `pragma_table_info` on a table that does not exist returns ZERO rows, so
|
|
// `centroidColumns.length === 0` and this whole branch is skipped on the retry.
|
|
// No second RENAME, therefore no `table already exists` — regardless of whether
|
|
// the DDL runs before or after migrate(). Verified by reversing the order: the
|
|
// suite stays green, which is exactly what told me the DDL explanation was
|
|
// wrong.
|
|
//
|
|
// The legacy name is second-resolution; two crashes inside one second still
|
|
// cannot collide because the second attempt never reaches the RENAME at all.
|
|
// MIG4 pins the recovery.
|
|
const centroidColumns = db
|
|
.prepare(`SELECT name FROM pragma_table_info('graph_centroid')`)
|
|
.all()
|
|
.map((row) => row.name)
|
|
if (centroidColumns.length > 0 && centroidColumns.includes('sum_json')) {
|
|
const rows = db.prepare('SELECT COUNT(*) AS n FROM graph_centroid').get().n
|
|
if (rows > 0) {
|
|
// Phase A never wrote a centroid, so this is a "should not happen" branch.
|
|
// Failing the whole open() would punish the user for a shape the plugin
|
|
// could not produce by normal use; dropping the rows would lose data.
|
|
// Renaming aside keeps both properties: startup continues, nothing is
|
|
// destroyed, and the old rows stay inspectable with sqlite3 if anyone cares.
|
|
const stamp = new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14)
|
|
const legacy = `graph_centroid_phase_a_${stamp}`
|
|
db.exec(`ALTER TABLE graph_centroid RENAME TO "${legacy}"`)
|
|
} else {
|
|
db.exec('DROP TABLE graph_centroid')
|
|
}
|
|
// Rebuild with the phase-B shape, right here (see the note above).
|
|
db.exec(DDL_CENTROID)
|
|
}
|
|
|
|
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
|
|
return SCHEMA_VERSION
|
|
}
|
|
|
|
/** Phase-B `graph_centroid` shape, reused by the migration to rebuild the table. */
|
|
const DDL_CENTROID = `CREATE TABLE IF NOT EXISTS graph_centroid (
|
|
fingerprint TEXT PRIMARY KEY,
|
|
dim INTEGER NOT NULL,
|
|
vector_count INTEGER NOT NULL,
|
|
vector TEXT NOT NULL,
|
|
stats TEXT NOT NULL DEFAULT '{}',
|
|
is_anomalous INTEGER NOT NULL DEFAULT 0,
|
|
built_at INTEGER NOT NULL
|
|
)`
|
|
|
|
const DDL = `
|
|
CREATE TABLE IF NOT EXISTS memory_blocks (
|
|
id TEXT PRIMARY KEY,
|
|
modality TEXT NOT NULL,
|
|
text_content TEXT NOT NULL DEFAULT '',
|
|
source TEXT NOT NULL DEFAULT '',
|
|
scene TEXT NOT NULL DEFAULT '',
|
|
-- semantic_type mirrors memory_blocks.semantic_type (graph.go:2577)
|
|
semantic_type TEXT NOT NULL DEFAULT '',
|
|
-- ── phase B: vectors ──
|
|
-- vector is a JSON array, matching HA's memory_blocks.vector (graph.go:2577).
|
|
-- Empty string (never 'null', never a zero vector) means "not embedded yet" —
|
|
-- an invented zero vector would participate in scoring and always rank last,
|
|
-- which is a silently wrong memory (distill/blocks.go:110-114).
|
|
vector TEXT NOT NULL DEFAULT '',
|
|
-- fingerprint is the vector-space identity this row's vector belongs to.
|
|
--
|
|
-- ★ It MUST be the same string that graph_centroid.fingerprint holds for the
|
|
-- same space. HA looks the centroid up BY fingerprint
|
|
-- (block_recall.go:92 -> LoadCentroid(q.Fingerprint, ...)); two tables
|
|
-- storing two spellings makes that lookup miss silently — centering simply
|
|
-- never applies, with no error anywhere. Pinned by the two-table-fp test.
|
|
fingerprint TEXT NOT NULL DEFAULT '',
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS memory_block_edges (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
source_kind TEXT NOT NULL,
|
|
source_id TEXT NOT NULL,
|
|
target_kind TEXT NOT NULL,
|
|
target_id TEXT NOT NULL,
|
|
type TEXT NOT NULL,
|
|
confidence REAL NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT '',
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_edge_identity
|
|
ON memory_block_edges(source_kind, source_id, target_kind, target_id, type);
|
|
CREATE INDEX IF NOT EXISTS idx_edge_source
|
|
ON memory_block_edges(source_kind, source_id);
|
|
CREATE INDEX IF NOT EXISTS idx_edge_target
|
|
ON memory_block_edges(target_kind, target_id);
|
|
|
|
-- graph_centroid: one row per vector space.
|
|
--
|
|
-- Column names follow HA's ddlCentroid (centroid_store.go:58-66) so the two
|
|
-- implementations stay legible side by side: vector_count / vector / stats /
|
|
-- built_at.
|
|
--
|
|
-- is_anomalous is its OWN column rather than a field inside stats.
|
|
-- LoadCentroid decides whether to enable centering from meta.Anomalous
|
|
-- (centroid_store.go:205-209); putting that decision inside a JSON blob would
|
|
-- mean every reader has to parse the blob to learn the one fact that gates the
|
|
-- whole feature. HA keeps it in stats; this plugin lifts it out deliberately.
|
|
|
|
|
|
${DDL_CENTROID};
|
|
|
|
-- FTS5 index over block text. External-content-free (content stored twice) on
|
|
-- purpose: rows are small, and a plain FTS table keeps deletes trivial.
|
|
CREATE VIRTUAL TABLE IF NOT EXISTS memory_blocks_fts
|
|
USING fts5(
|
|
text_content,
|
|
block_id UNINDEXED,
|
|
-- unicode61 with remove_diacritics handles Latin; CJK needs the trigram
|
|
-- tokenizer to be useful at all (see store.js recall notes).
|
|
tokenize = 'unicode61 remove_diacritics 2'
|
|
);
|
|
`
|
|
|
|
/**
|
|
* Deterministic content-derived block id, ported from
|
|
* `distill.BlockID` (internal/memory/distill/blocks.go:99):
|
|
* `sha256(sentence \0 subject \0 dimension \0 value)[:12]` prefixed `blk_`.
|
|
*
|
|
* The MVP slice has no splitter, so the id derives from the block text alone —
|
|
* same idea (content-addressed ⇒ idempotent re-commit), fewer inputs.
|
|
*/
|
|
export function blockId(text) {
|
|
// createHash avoids a dependency; node:crypto is built in.
|
|
return 'blk_' + createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 12)
|
|
}
|
|
|
|
/**
|
|
* A block memory store backed by one SQLite file.
|
|
*
|
|
* Lifecycle: construct → `open()` → use → `close()`. `close()` is idempotent so
|
|
* a plugin `dispose` and a test teardown can both call it (checkpoint V5).
|
|
*/
|
|
export class BlockStore {
|
|
/** @type {DatabaseSync | null} */
|
|
#db = null
|
|
/** @type {string} */
|
|
#path
|
|
|
|
/**
|
|
* @param {object} options
|
|
* @param {string} options.path absolute path, or ':memory:' for tests
|
|
* @param {boolean} [options.wal] enable WAL (default true; ignored for :memory:)
|
|
*/
|
|
constructor({ path, wal = true }) {
|
|
this.#path = path === ':memory:' ? ':memory:' : resolve(path)
|
|
this.#wantsWal = wal
|
|
}
|
|
|
|
#wantsWal
|
|
|
|
/**
|
|
* @returns {boolean} whether the handle is open.
|
|
*
|
|
* Named `isOpen`, not `open` — a getter and a method cannot share a name, and
|
|
* the method (which opens the handle) is the one callers reach for.
|
|
*/
|
|
get isOpen() {
|
|
return this.#db !== null
|
|
}
|
|
|
|
/** @returns {string} the resolved database path */
|
|
get path() {
|
|
return this.#path
|
|
}
|
|
|
|
/**
|
|
* Open the handle and create the schema.
|
|
* @returns {this}
|
|
*/
|
|
open() {
|
|
if (this.#db !== null) return this
|
|
if (this.#path !== ':memory:') mkdirSync(dirname(this.#path), { recursive: true })
|
|
const db = new DatabaseSync(this.#path)
|
|
// WAL lets a reader run while a writer holds the write lock. Kept off for
|
|
// :memory: where it is meaningless and would only add a journal file.
|
|
if (this.#wantsWal && this.#path !== ':memory:') db.exec('PRAGMA journal_mode = WAL')
|
|
db.exec('PRAGMA foreign_keys = ON')
|
|
db.exec(DDL)
|
|
// After DDL: a brand-new database now has the current shape, so the
|
|
// migration only has work to do for databases that predate phase B.
|
|
migrate(db)
|
|
this.#db = db
|
|
return this
|
|
}
|
|
|
|
/** @returns {DatabaseSync} the open handle @throws when closed */
|
|
#handle() {
|
|
if (this.#db === null) throw new Error('dsh-memory: store is not open')
|
|
return this.#db
|
|
}
|
|
|
|
/**
|
|
* Insert or update one text block and its FTS row, atomically.
|
|
*
|
|
* Ports the "empty value does not overwrite" rule from `PutMemoryBlocks`
|
|
* (internal/memory/block.go:203-300) in its MVP-relevant part: re-committing
|
|
* the same text must not blank `source` / `scene` / `semantic_type`.
|
|
*
|
|
* @param {object} block
|
|
* @param {string} block.text block body (the payload; `memory_blocks.text_content`)
|
|
* @param {string} [block.source] where it came from
|
|
* @param {string} [block.scene] scene key
|
|
* @param {string} [block.semanticType] semantic category
|
|
* @param {string} [block.id] explicit id; defaults to content-derived
|
|
* @returns {{ id: string, created: boolean }} the id and whether it was new
|
|
*/
|
|
putBlock({ text, source = '', scene = '', semanticType = '', id, vector, fingerprint }) {
|
|
const db = this.#handle()
|
|
const trimmed = String(text ?? '').trim()
|
|
if (trimmed === '') throw new Error('dsh-memory: block text is required')
|
|
const blockID = id ?? blockId(trimmed)
|
|
const now = Date.now()
|
|
// `vector` may be omitted (no embedder) — stored as '' meaning "not embedded".
|
|
// A vector without a fingerprint is refused: an unlabelled vector cannot be
|
|
// compared to anything, and admitting one is exactly the silent cross-space
|
|
// mixing that §0.2 fail-closed exists to prevent.
|
|
const vectorJSON = Array.isArray(vector) && vector.length > 0 ? JSON.stringify(vector) : ''
|
|
if (vectorJSON !== '' && (typeof fingerprint !== 'string' || fingerprint === '')) {
|
|
throw new Error('dsh-memory: a block with a vector requires the vector-space fingerprint')
|
|
}
|
|
const vectorFP = vectorJSON === '' ? '' : fingerprint
|
|
|
|
db.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const existing = db.prepare('SELECT id FROM memory_blocks WHERE id = ?').get(blockID)
|
|
const created = existing === undefined
|
|
// ON CONFLICT: every optional column keeps its old value when the new one
|
|
// is empty — the same "空值不覆盖" policy as PutMemoryBlocks.
|
|
db.prepare(
|
|
`INSERT INTO memory_blocks
|
|
(id, modality, text_content, source, scene, semantic_type,
|
|
vector, fingerprint, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
text_content = excluded.text_content,
|
|
source = CASE WHEN excluded.source != '' THEN excluded.source ELSE memory_blocks.source END,
|
|
scene = CASE WHEN excluded.scene != '' THEN excluded.scene ELSE memory_blocks.scene END,
|
|
semantic_type = CASE WHEN excluded.semantic_type != '' THEN excluded.semantic_type ELSE memory_blocks.semantic_type END,
|
|
-- Same "empty does not overwrite" rule as PutMemoryBlocks: a re-commit
|
|
-- that carries no vector must not erase a vector computed earlier.
|
|
vector = CASE WHEN excluded.vector != '' THEN excluded.vector ELSE memory_blocks.vector END,
|
|
fingerprint = CASE WHEN excluded.fingerprint != '' THEN excluded.fingerprint ELSE memory_blocks.fingerprint END,
|
|
updated_at = excluded.updated_at`,
|
|
).run(blockID, MODALITY_TEXT, trimmed, source, scene, semanticType,
|
|
vectorJSON, vectorFP, now, now)
|
|
|
|
// FTS is not auto-maintained; delete-then-insert keeps it in step.
|
|
db.prepare('DELETE FROM memory_blocks_fts WHERE block_id = ?').run(blockID)
|
|
db.prepare('INSERT INTO memory_blocks_fts (text_content, block_id) VALUES (?, ?)').run(
|
|
trimmed,
|
|
blockID,
|
|
)
|
|
db.exec('COMMIT')
|
|
return { id: blockID, created }
|
|
} catch (error) {
|
|
db.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add one structural/semantic edge. Endpoint existence is NOT validated here
|
|
* (HologramAgent validates it in `AddMemoryBlockEdge`, block.go:497); this
|
|
* slice deliberately allows edges to arbitrary `kind`s so scene/document
|
|
* endpoints can be added in the L2 phase without a schema change.
|
|
*
|
|
* @param {object} edge
|
|
* @returns {{ created: boolean }}
|
|
*/
|
|
addEdge({ sourceKind, sourceId, targetKind, targetId, type, confidence = 0, status = '' }) {
|
|
const db = this.#handle()
|
|
for (const [name, value] of Object.entries({ sourceKind, sourceId, targetKind, targetId, type })) {
|
|
if (typeof value !== 'string' || value === '') {
|
|
throw new Error(`dsh-memory: edge.${name} is required`)
|
|
}
|
|
}
|
|
const result = db
|
|
.prepare(
|
|
`INSERT INTO memory_block_edges
|
|
(source_kind, source_id, target_kind, target_id, type, confidence, status, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(source_kind, source_id, target_kind, target_id, type) DO NOTHING`,
|
|
)
|
|
.run(sourceKind, sourceId, targetKind, targetId, type, confidence, status, Date.now())
|
|
return { created: result.changes > 0 }
|
|
}
|
|
|
|
/** @returns {object | undefined} one block row by id */
|
|
getBlock(id) {
|
|
return this.#handle().prepare('SELECT * FROM memory_blocks WHERE id = ?').get(id)
|
|
}
|
|
|
|
/**
|
|
* Persist one vector-space centroid.
|
|
*
|
|
* ★ The fingerprint written here MUST be the exact same string that
|
|
* `putBlock` stores on each block of that space. HA looks the centroid up by
|
|
* fingerprint (`block_recall.go:92`), so two spellings make the lookup miss
|
|
* with no error at all — centering just never happens. The two-table
|
|
* fingerprint test pins this.
|
|
*
|
|
* @param {object} centroid
|
|
* @param {string} centroid.fingerprint
|
|
* @param {number} centroid.dim
|
|
* @param {number} centroid.vectorCount
|
|
* @param {number[]} centroid.mean the mean vector (already normalised)
|
|
* @param {boolean} centroid.isAnomalous whether centering is worth enabling
|
|
* @param {object} [centroid.stats] free-form diagnostics
|
|
* @returns {void}
|
|
*/
|
|
saveCentroid({ fingerprint, dim, vectorCount, mean, isAnomalous, stats = {} }) {
|
|
if (typeof fingerprint !== 'string' || fingerprint === '') {
|
|
throw new Error('dsh-memory: centroid requires a fingerprint')
|
|
}
|
|
if (!Array.isArray(mean) || mean.length !== dim) {
|
|
throw new Error(`dsh-memory: centroid mean must be ${dim}-dimensional`)
|
|
}
|
|
this.#handle()
|
|
.prepare(
|
|
`INSERT INTO graph_centroid
|
|
(fingerprint, dim, vector_count, vector, stats, is_anomalous, built_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(fingerprint) DO UPDATE SET
|
|
dim = excluded.dim,
|
|
vector_count = excluded.vector_count,
|
|
vector = excluded.vector,
|
|
stats = excluded.stats,
|
|
is_anomalous = excluded.is_anomalous,
|
|
built_at = excluded.built_at`,
|
|
)
|
|
.run(fingerprint, dim, vectorCount, JSON.stringify(mean), JSON.stringify(stats),
|
|
isAnomalous ? 1 : 0, Date.now())
|
|
}
|
|
|
|
/** Mean-vector L2 norm at or above which centering is worth enabling.
|
|
* Ported from `meanThreshold` (internal/memory/centroid.go:87). Measured
|
|
* rationale: at norm 0.9374 de-meaning lifts separation 7x; near 0 the space is
|
|
* already isotropic and centering only amplifies noise. */
|
|
static get MEAN_THRESHOLD() {
|
|
return 0.6
|
|
}
|
|
|
|
/**
|
|
* Rebuild the centroid for one vector space FROM ITS BLOCKS.
|
|
*
|
|
* This is the missing entry point: without it `graph_centroid` stays empty
|
|
* forever while blocks keep carrying vectors, `loadCentroid` always misses, and
|
|
* centering silently never applies — the票③ "dim unknown" state would become
|
|
* the permanent normal case rather than an edge case.
|
|
*
|
|
* Port of `RebuildCentroid` (internal/memory/centroid_store.go:246-320):
|
|
* · only rows that carry a vector in THIS space take part
|
|
* · a row whose dimension differs from the first is skipped, not coerced
|
|
* · the mean is accumulated over normalised vectors, then its norm decides
|
|
* whether the space is anomalous enough to centre at all
|
|
*
|
|
* @param {string} fingerprint the space to rebuild
|
|
* @returns {{ fingerprint: string, dim: number, vectorCount: number,
|
|
* meanLength: number, isAnomalous: boolean, saved: boolean }}
|
|
* @throws when the space has no embedded blocks to estimate from
|
|
*/
|
|
rebuildCentroid(fingerprint) {
|
|
if (typeof fingerprint !== 'string' || fingerprint === '') {
|
|
throw new Error('dsh-memory: rebuildCentroid requires a fingerprint')
|
|
}
|
|
const rows = this.#handle()
|
|
.prepare(
|
|
`SELECT vector FROM memory_blocks
|
|
WHERE fingerprint = ? AND vector != '' AND vector != 'null'`,
|
|
)
|
|
.all(fingerprint)
|
|
if (rows.length === 0) {
|
|
throw new Error(`dsh-memory: no embedded blocks for space ${fingerprint}`)
|
|
}
|
|
|
|
let dim = 0
|
|
const vectors = []
|
|
for (const row of rows) {
|
|
let vec
|
|
try {
|
|
vec = JSON.parse(row.vector)
|
|
} catch {
|
|
continue // a corrupt row must not poison the estimate
|
|
}
|
|
if (!Array.isArray(vec) || vec.length === 0) continue
|
|
if (dim === 0) dim = vec.length
|
|
if (vec.length !== dim) continue // mixed provider: skip, never coerce
|
|
vectors.push(vec)
|
|
}
|
|
if (vectors.length === 0) {
|
|
throw new Error(`dsh-memory: no usable vectors for space ${fingerprint}`)
|
|
}
|
|
|
|
// Mean over L2-normalised vectors, so a long vector cannot dominate.
|
|
const sum = new Array(dim).fill(0)
|
|
for (const vec of vectors) {
|
|
let norm = 0
|
|
for (const value of vec) norm += value * value
|
|
norm = Math.sqrt(norm)
|
|
if (norm === 0) continue
|
|
for (let i = 0; i < dim; i += 1) sum[i] += vec[i] / norm
|
|
}
|
|
const mean = sum.map((value) => value / vectors.length)
|
|
let meanLength = 0
|
|
for (const value of mean) meanLength += value * value
|
|
meanLength = Math.sqrt(meanLength)
|
|
const isAnomalous = meanLength >= BlockStore.MEAN_THRESHOLD
|
|
|
|
this.saveCentroid({
|
|
fingerprint,
|
|
dim,
|
|
vectorCount: vectors.length,
|
|
mean,
|
|
isAnomalous,
|
|
stats: {
|
|
source: 'rebuildCentroid',
|
|
meanLength,
|
|
avgPairCosine: meanLength * meanLength,
|
|
skippedRows: rows.length - vectors.length,
|
|
},
|
|
})
|
|
return { fingerprint, dim, vectorCount: vectors.length, meanLength, isAnomalous, saved: true }
|
|
}
|
|
|
|
/**
|
|
* Read one centroid, or undefined.
|
|
* @param {string} fingerprint
|
|
* @returns {{fingerprint: string, dim: number, vectorCount: number,
|
|
* mean: number[], isAnomalous: boolean, stats: object} | undefined}
|
|
*/
|
|
loadCentroid(fingerprint) {
|
|
const row = this.#handle()
|
|
.prepare('SELECT * FROM graph_centroid WHERE fingerprint = ?')
|
|
.get(fingerprint)
|
|
if (row === undefined) return undefined
|
|
return {
|
|
fingerprint: row.fingerprint,
|
|
dim: row.dim,
|
|
vectorCount: row.vector_count,
|
|
mean: JSON.parse(row.vector),
|
|
isAnomalous: row.is_anomalous === 1,
|
|
stats: JSON.parse(row.stats),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Distinct fingerprints actually present on blocks, with counts.
|
|
*
|
|
* Read-only and, importantly, does NOT parse vectors: it groups on the
|
|
* `fingerprint` column alone. Grouping must stay cheap because the stats tool
|
|
* calls it on every use.
|
|
*
|
|
* @returns {Array<{fingerprint: string, blocks: number, embedded: number}>}
|
|
*/
|
|
blockFingerprints() {
|
|
return this.#handle()
|
|
.prepare(
|
|
`SELECT fingerprint,
|
|
COUNT(*) AS blocks,
|
|
SUM(CASE WHEN vector != '' THEN 1 ELSE 0 END) AS embedded
|
|
FROM memory_blocks
|
|
WHERE fingerprint != ''
|
|
GROUP BY fingerprint
|
|
ORDER BY fingerprint`,
|
|
)
|
|
.all()
|
|
.map((row) => ({ fingerprint: row.fingerprint, blocks: row.blocks, embedded: row.embedded }))
|
|
}
|
|
|
|
/** @returns {number} block count */
|
|
countBlocks() {
|
|
return this.#handle().prepare('SELECT COUNT(*) AS n FROM memory_blocks').get().n
|
|
}
|
|
|
|
/** @returns {number} edge count */
|
|
countEdges() {
|
|
return this.#handle().prepare('SELECT COUNT(*) AS n FROM memory_block_edges').get().n
|
|
}
|
|
|
|
/**
|
|
* Column names of one table.
|
|
*
|
|
* Introduced for the group-G guard that asserts this slice never grows media
|
|
* columns — the check needs schema introspection, and exposing it here beats
|
|
* reaching into the private handle from a test.
|
|
*
|
|
* @param {string} table table name (asserted against sqlite_master first)
|
|
* @returns {string[]}
|
|
*/
|
|
columns(table) {
|
|
const db = this.#handle()
|
|
const exists = db
|
|
.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name=?")
|
|
.get(table).n
|
|
if (exists === 0) throw new Error(`dsh-memory: no such table: ${table}`)
|
|
return db.prepare(`SELECT name FROM pragma_table_info(?)`).all(table).map((row) => row.name)
|
|
}
|
|
|
|
/**
|
|
* @returns {string[]} user table names (excludes sqlite_ internals)
|
|
*/
|
|
tables() {
|
|
return this.#handle()
|
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
|
.all()
|
|
.map((row) => row.name)
|
|
}
|
|
|
|
/**
|
|
* The raw handle, for assertions the public surface deliberately does not
|
|
* expose (e.g. counting rows in a renamed legacy table). Tests only.
|
|
* @returns {import('node:sqlite').DatabaseSync}
|
|
*/
|
|
raw() {
|
|
return this.#handle()
|
|
}
|
|
|
|
/** @returns {number} the schema version recorded in `PRAGMA user_version` */
|
|
userVersion() {
|
|
return this.#handle().prepare('PRAGMA user_version').get().user_version
|
|
}
|
|
|
|
/** @returns {string} the journal mode actually in force */
|
|
journalMode() {
|
|
return this.#handle().prepare('PRAGMA journal_mode').get().journal_mode
|
|
}
|
|
|
|
/**
|
|
* FTS5 MATCH recall, optionally restricted to rows whose text also contains
|
|
* an exact symbol (see `recall.js` for why both signals are needed).
|
|
*
|
|
* @param {string} match an FTS5 query string (already quoted/sanitised)
|
|
* @param {number} limit
|
|
* @returns {Array<{id: string, text: string, source: string, score: number}>}
|
|
*/
|
|
searchFts(match, limit = 10) {
|
|
const db = this.#handle()
|
|
// bm25() returns a NEGATIVE score where more negative == better; flip it so
|
|
// callers sort descending. FTS5 `rank` is the same value; bm25 is explicit.
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT b.id, b.text_content, b.source, bm25(memory_blocks_fts) AS raw
|
|
FROM memory_blocks_fts f
|
|
JOIN memory_blocks b ON b.id = f.block_id
|
|
WHERE memory_blocks_fts MATCH ?
|
|
ORDER BY raw
|
|
LIMIT ?`,
|
|
)
|
|
.all(match, limit)
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
text: row.text_content,
|
|
source: row.source,
|
|
// Normalise: bm25 is unbounded negative; map into (0,1) monotonically
|
|
// with 1/(1+|raw|) so exact/rare matches rank highest.
|
|
score: 1 / (1 + Math.abs(row.raw)),
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* All blocks, newest first. Used as the LIKE fallback corpus and by tests.
|
|
* @param {number} [limit]
|
|
*/
|
|
allBlocks(limit = 1000) {
|
|
return this.#handle()
|
|
.prepare('SELECT * FROM memory_blocks ORDER BY created_at DESC, id LIMIT ?')
|
|
.all(limit)
|
|
}
|
|
|
|
/** Close the handle. Idempotent (checkpoint V5). */
|
|
close() {
|
|
if (this.#db === null) return
|
|
try {
|
|
this.#db.close()
|
|
} finally {
|
|
this.#db = null
|
|
}
|
|
}
|
|
}
|