Merge branch 'next/zero-norm-unvector-door'
# Conflicts: # src/hnsw/hnswIndex.ts
This commit is contained in:
commit
9b84ef5b02
6 changed files with 798 additions and 54 deletions
351
src/brainy.ts
351
src/brainy.ts
|
|
@ -396,6 +396,15 @@ interface PlannedTransact {
|
|||
* marker outlives its write.
|
||||
*/
|
||||
markerRecords: FactMarkerRecord[]
|
||||
/**
|
||||
* Ids the batch's `{ op: 'update' }` unvector door (`vector: []`) needs to
|
||||
* decrement on the vectored-noun ledger — consumed by `transact()` with a
|
||||
* proper `await this.storage.noteVectorUnlanded?.(id)` per id, AFTER
|
||||
* `commitTransaction` resolves (never for a rejected batch). Kept separate
|
||||
* from `postCommit` (`Array<() => void>`, called synchronously, fire-and-
|
||||
* forget) because the ledger hook is async and must be awaited.
|
||||
*/
|
||||
vectorUnlands: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1504,6 +1513,15 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
}).backfillBlobHistoryRefCountsIfNeeded()
|
||||
}
|
||||
|
||||
// LEG C (zero-norm/unvector-door law): migrate a legacy zero-norm VFS
|
||||
// root BEFORE the vector-leg open gate below ever compares the
|
||||
// canonical vectored-noun count against the vector index's size — see
|
||||
// migrateLegacyZeroNormVfsRootIfNeeded's JSDoc for why this is a safe
|
||||
// O(1) exception to "nothing at open may scale with brain size", and
|
||||
// why it must run here rather than waiting on VirtualFileSystem's own
|
||||
// (VFS-instance-gated) lazy migration.
|
||||
await this.migrateLegacyZeroNormVfsRootIfNeeded()
|
||||
|
||||
// Rebuild indexes if needed for existing data. Runs to completion before
|
||||
// init() returns — there is no more first-query lazy path, so the flag
|
||||
// below (kept for getIndexStatus() API compatibility) simply flips true
|
||||
|
|
@ -1518,8 +1536,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
// cache, roaring, msgpack, sort:topK, distance), HNSW/metadata/graph
|
||||
// index construction, the eager cold-load, id-resolver + connections-
|
||||
// codec wiring, crash-recovery index rebuild, the replay-gap check,
|
||||
// legacy VFS blob adoption, blob-history backfill, and the
|
||||
// rebuildIndexesIfNeeded() gate + migration check.
|
||||
// legacy VFS blob adoption, blob-history backfill, the legacy
|
||||
// zero-norm VFS root migration, and the rebuildIndexesIfNeeded() gate
|
||||
// + migration check.
|
||||
markPhase('index-init-gate')
|
||||
|
||||
// Register shutdown hooks for graceful count flushing (once globally)
|
||||
|
|
@ -2912,10 +2931,34 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
// vector shape, is structurally impossible). The background worker
|
||||
// embeds + inserts.
|
||||
const deferringEmbed = params.deferEmbedding === true && !params.vector
|
||||
const vector = deferringEmbed
|
||||
let vector = deferringEmbed
|
||||
? []
|
||||
: params.vector || (await this.embed(params.data))
|
||||
|
||||
// THE ZERO-NORM LAW (canonical write side): a zero-norm vector is not a
|
||||
// vector — it never crosses an engine boundary (the engine pair's seam
|
||||
// law). This engine's own cosine distance treats an all-zero vector
|
||||
// safely (a zero-norm operand always scores MAXIMUM distance — see
|
||||
// isZeroNormVector's JSDoc), but a downstream engine serving squared-
|
||||
// euclidean distance cannot tell it apart from a legitimate origin
|
||||
// point — a false attractor that silently darkened 150+ rows in a
|
||||
// production deployment. The index belt (AddToVectorIndexOperation)
|
||||
// already refuses to INDEX a zero-norm vector, but until now the
|
||||
// CANONICAL write still persisted it and the vectored-noun ledger
|
||||
// counted it — so a near-empty store whose only vectored row was
|
||||
// zero-norm read "canonical vectored > 0, index size 0" and threw a
|
||||
// not-ready error at open. Normalize HERE, before the dimension pin,
|
||||
// the vectored-ledger flag (`SaveNounMetadataOperation`'s `hasVector`),
|
||||
// and the index ops below ever see it, so it persists as the sanctioned
|
||||
// "unvectored" `[]` shape instead — the canonical write still succeeds.
|
||||
if (!deferringEmbed && vector.length > 0 && isZeroNormVector(vector)) {
|
||||
prodLog.warn(
|
||||
`[Brainy] add(): entity ${id} was given an explicit all-zero vector — ` +
|
||||
`a zero-norm vector is not a vector; persisted unvectored ([]) instead.`
|
||||
)
|
||||
vector = []
|
||||
}
|
||||
|
||||
// Ensure dimensions are set (a deferred-embed stub carries no dimension
|
||||
// information — the worker's real vector goes through the same guard).
|
||||
// Gated on `vector.length > 0`, not `!deferringEmbed`: ANY insert whose
|
||||
|
|
@ -3623,25 +3666,53 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
// often the host writes.
|
||||
const dataUnchanged = rawHasNewData && Brainy.sameEntityData(params.data, existing.data)
|
||||
const hasNewData = rawHasNewData && !dataUnchanged
|
||||
|
||||
// THE ZERO-NORM LAW (canonical write side) — see add()'s matching
|
||||
// comment: an explicit REAL all-zero vector is not a vector. Normalize
|
||||
// to the sanctioned "unvectored" `[]` shape BEFORE the dimension
|
||||
// check, the unvector-door decision below, and the index ops ever see
|
||||
// it — a local copy; `params.vector` itself is never mutated.
|
||||
let explicitVector = params.vector
|
||||
if (explicitVector && explicitVector.length > 0 && isZeroNormVector(explicitVector)) {
|
||||
prodLog.warn(
|
||||
`[Brainy] update(): entity ${params.id} was given an explicit all-zero vector — ` +
|
||||
`a zero-norm vector is not a vector; persisted unvectored ([]) instead.`
|
||||
)
|
||||
explicitVector = []
|
||||
}
|
||||
|
||||
// THE SANCTIONED UNVECTOR DOOR: `explicitVector` at length 0 (an
|
||||
// explicit `vector: []`, or a real all-zero vector just normalized
|
||||
// above) is an instruction to remove the vector NOW — never "please
|
||||
// embed". `validateUpdateParams` already refuses combining it with
|
||||
// `deferEmbedding: true` (an empty array is truthy, so that guard
|
||||
// fires unconditionally on any explicit `vector`). Idempotent on an
|
||||
// already-unvectored row: the ledger decrement near the end of this
|
||||
// method is gated on the PRIOR vector actually having been real.
|
||||
const isExplicitUnvector = explicitVector !== undefined && explicitVector.length === 0
|
||||
|
||||
// MT5 deferred re-embedding: the OLD vector keeps serving semantic
|
||||
// search — stale-but-present, never absent (the flicker law) — until
|
||||
// the background worker embeds the new data and swaps it atomically.
|
||||
const deferringEmbed =
|
||||
params.deferEmbedding === true && hasNewData && !params.vector
|
||||
if (params.vector) {
|
||||
if (this.dimensions && params.vector.length !== this.dimensions) {
|
||||
params.deferEmbedding === true && hasNewData && !explicitVector
|
||||
if (explicitVector) {
|
||||
// A length-0 explicit vector (the unvector door) carries no
|
||||
// dimension information — exempt from the check, mirroring add()'s
|
||||
// own `vector.length > 0` gate on the dimension pin.
|
||||
if (explicitVector.length > 0 && this.dimensions && explicitVector.length !== this.dimensions) {
|
||||
throw new Error(
|
||||
`Vector dimension mismatch: expected ${this.dimensions}, got ${params.vector.length}`
|
||||
`Vector dimension mismatch: expected ${this.dimensions}, got ${explicitVector.length}`
|
||||
)
|
||||
}
|
||||
vector = params.vector
|
||||
vector = explicitVector
|
||||
} else if (hasNewData && !deferringEmbed) {
|
||||
vector = await this.embed(params.data)
|
||||
}
|
||||
// A deferred data change does NOT reindex now (the vector is unchanged;
|
||||
// the worker's atomic swap carries the real reindex later).
|
||||
const needsReindexing = Boolean(
|
||||
(hasNewData && !deferringEmbed) || params.type || params.vector
|
||||
(hasNewData && !deferringEmbed) || params.type || explicitVector
|
||||
)
|
||||
|
||||
// Always update the noun with new metadata
|
||||
|
|
@ -3735,6 +3806,22 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
? [this.enqueuePendingEmbed(params.id)]
|
||||
: undefined
|
||||
|
||||
// Leg D — the unvector door clears a PENDING deferred-embed marker:
|
||||
// without this, the worker would later embed this row's current data
|
||||
// and silently re-vector it, defeating the caller's explicit "remove
|
||||
// the vector now" instruction. The clear rides THIS SAME commit fact
|
||||
// (an `embed.landed` record with an empty vector — the recovery fold
|
||||
// disarms a pending marker on ANY `embed.landed` for the id,
|
||||
// regardless of the vector it carries), so a crash between the write
|
||||
// and the in-memory clear below still recovers disarmed. Mutually
|
||||
// exclusive with `embedMarkers` above: `deferringEmbed` requires an
|
||||
// ABSENT `explicitVector`, so the two branches never both apply.
|
||||
const clearsPendingEmbed = isExplicitUnvector && this._pendingEmbedIds.has(params.id)
|
||||
const commitRecords: FactMarkerRecord[] | undefined =
|
||||
embedMarkers ?? (clearsPendingEmbed
|
||||
? [{ type: 'embed.landed', id: params.id, vector: [] }]
|
||||
: undefined)
|
||||
|
||||
// Execute atomically with transaction system, generation-stamped as one
|
||||
// immutable Model-B generation (before-image = the entity's prior state).
|
||||
await this.persistSingleOp({ nouns: [params.id] }, async (tx) => {
|
||||
|
|
@ -3823,7 +3910,33 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
}
|
||||
}
|
||||
]
|
||||
: undefined, embedMarkers)
|
||||
: undefined, commitRecords)
|
||||
|
||||
// Leg D continued — the in-memory pending-embed clear runs only AFTER
|
||||
// the commit above actually succeeded (an aborted update must not
|
||||
// disarm a marker whose durable `embed.landed` twin was never
|
||||
// written).
|
||||
if (clearsPendingEmbed) {
|
||||
this.clearPendingEmbed(params.id)
|
||||
prodLog.warn(
|
||||
`[Brainy] update(): entity ${params.id} had a pending deferred embed — ` +
|
||||
`the unvector door cleared it ('vector: []' is an explicit instruction, ` +
|
||||
`never "please embed").`
|
||||
)
|
||||
}
|
||||
|
||||
// Leg D — vectored-ledger decrement for the sanctioned unvector door.
|
||||
// update()'s own metadata write goes through UpdateNounMetadataOperation
|
||||
// (isNew=false), so the saveNounMetadata(..., hasVector) seam never
|
||||
// fires here — noteVectorUnlanded is the ONLY seam, the same
|
||||
// sanctioned hook unvectorNounForRootMigration() uses. Gated on the
|
||||
// PRIOR vector having actually been real (non-empty, non-zero-norm):
|
||||
// an already-unvectored row's second call is a true no-op — no
|
||||
// decrement, matching the ledger-exactness law (never double-count,
|
||||
// never drift negative).
|
||||
if (isExplicitUnvector && existing.vector.length > 0 && !isZeroNormVector(existing.vector)) {
|
||||
await this.storage.noteVectorUnlanded?.(params.id)
|
||||
}
|
||||
|
||||
// Aggregation hook (outside transaction — derived data). `existing` is
|
||||
// the full get() view — every reserved field top-level — and must be
|
||||
|
|
@ -9179,6 +9292,15 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
hook()
|
||||
}
|
||||
|
||||
// Leg D — vectored-ledger decrements for this batch's unvector-door
|
||||
// updates (see planTxUpdate's matching comment), applied after the
|
||||
// commit point and properly awaited (unlike `postCommit`'s synchronous
|
||||
// fire-and-forget hooks) — each is the same sanctioned hook
|
||||
// unvectorNounForRootMigration() uses.
|
||||
for (const id of plan.vectorUnlands) {
|
||||
await this.storage.noteVectorUnlanded?.(id)
|
||||
}
|
||||
|
||||
// Change feed: the batch's events share its single committed generation.
|
||||
// A rejected batch throws at commitTransaction and never reaches here.
|
||||
this.emitCommitted(plan.changeEvents, undefined, generation, timestamp)
|
||||
|
|
@ -10422,7 +10544,8 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
casUpdates: [],
|
||||
createdNouns: new Set(),
|
||||
changeEvents: [],
|
||||
markerRecords: []
|
||||
markerRecords: [],
|
||||
vectorUnlands: []
|
||||
}
|
||||
|
||||
for (const op of ops) {
|
||||
|
|
@ -10544,9 +10667,22 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
// marker-less committed row would be a silently missing vector, which is
|
||||
// the disallowed direction). The background worker embeds + inserts.
|
||||
const deferringEmbed = params.deferEmbedding === true && !params.vector
|
||||
const vector = deferringEmbed
|
||||
let vector = deferringEmbed
|
||||
? []
|
||||
: params.vector || (await this.embed(params.data))
|
||||
|
||||
// THE ZERO-NORM LAW — see the single-add() insert path's matching
|
||||
// comment (a zero-norm vector is not a vector; never crosses an engine
|
||||
// boundary). Normalized here BEFORE the dimension pin and the
|
||||
// vectored-ledger `hasVector` flag below ever see it.
|
||||
if (!deferringEmbed && vector.length > 0 && isZeroNormVector(vector)) {
|
||||
prodLog.warn(
|
||||
`[Brainy] transact add: entity ${id} was given an explicit all-zero vector — ` +
|
||||
`a zero-norm vector is not a vector; persisted unvectored ([]) instead.`
|
||||
)
|
||||
vector = []
|
||||
}
|
||||
|
||||
// Gated on `vector.length > 0` — see the single-add() insert path's
|
||||
// matching comment: an explicit `vector: []` carries no dimension
|
||||
// information either, deferred or not.
|
||||
|
|
@ -10717,17 +10853,62 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
const dataUnchanged = rawHasNewData && Brainy.sameEntityData(params.data, existing.data)
|
||||
const hasNewData = rawHasNewData && !dataUnchanged
|
||||
let vector = existing.vector
|
||||
if (params.vector) {
|
||||
if (this.dimensions && params.vector.length !== this.dimensions) {
|
||||
|
||||
// THE ZERO-NORM LAW + THE SANCTIONED UNVECTOR DOOR — transact() mirror
|
||||
// of update()'s matching block: an explicit REAL all-zero vector
|
||||
// normalizes to `[]` (never crosses an engine boundary), and an
|
||||
// explicit `vector: []` (post-normalization) is the sanctioned unvector
|
||||
// instruction, exempt from the dimension check. `validateUpdateParams`
|
||||
// already refuses combining it with `deferEmbedding: true`.
|
||||
let explicitVector = params.vector
|
||||
if (explicitVector && explicitVector.length > 0 && isZeroNormVector(explicitVector)) {
|
||||
prodLog.warn(
|
||||
`[Brainy] transact update: entity ${params.id} was given an explicit all-zero ` +
|
||||
`vector — a zero-norm vector is not a vector; persisted unvectored ([]) instead.`
|
||||
)
|
||||
explicitVector = []
|
||||
}
|
||||
const isExplicitUnvector = explicitVector !== undefined && explicitVector.length === 0
|
||||
|
||||
if (explicitVector) {
|
||||
if (explicitVector.length > 0 && this.dimensions && explicitVector.length !== this.dimensions) {
|
||||
throw new Error(
|
||||
`Vector dimension mismatch: expected ${this.dimensions}, got ${params.vector.length}`
|
||||
`Vector dimension mismatch: expected ${this.dimensions}, got ${explicitVector.length}`
|
||||
)
|
||||
}
|
||||
vector = params.vector
|
||||
vector = explicitVector
|
||||
} else if (hasNewData) {
|
||||
vector = await this.embed(params.data)
|
||||
}
|
||||
const needsReindexing = Boolean(hasNewData || params.type || params.vector)
|
||||
const needsReindexing = Boolean(hasNewData || params.type || explicitVector)
|
||||
|
||||
// Leg D — the unvector door clears a PENDING deferred-embed marker (see
|
||||
// update()'s matching comment for the full rationale): the durable
|
||||
// clear (an `embed.landed` record, empty vector) rides the batch's ONE
|
||||
// commit fact via `plan.markerRecords`; the in-memory clear is deferred
|
||||
// to `plan.postCommit` so an aborted batch never disarms a marker whose
|
||||
// durable twin was never written.
|
||||
const clearsPendingEmbed = isExplicitUnvector && this._pendingEmbedIds.has(params.id)
|
||||
if (clearsPendingEmbed) {
|
||||
plan.markerRecords.push({ type: 'embed.landed', id: params.id, vector: [] })
|
||||
plan.postCommit.push(() => {
|
||||
this.clearPendingEmbed(params.id)
|
||||
prodLog.warn(
|
||||
`[Brainy] transact update: entity ${params.id} had a pending deferred embed — ` +
|
||||
`the unvector door cleared it ('vector: []' is an explicit instruction, ` +
|
||||
`never "please embed").`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Leg D — vectored-ledger decrement for the sanctioned unvector door,
|
||||
// deferred to `plan.vectorUnlands` (consumed with a proper `await` in
|
||||
// `transact()`, AFTER the commit succeeds — see its matching comment).
|
||||
// Gated on the PRIOR vector having actually been real (non-empty,
|
||||
// non-zero-norm): idempotent on an already-unvectored row.
|
||||
if (isExplicitUnvector && existing.vector.length > 0 && !isZeroNormVector(existing.vector)) {
|
||||
plan.vectorUnlands.push(params.id)
|
||||
}
|
||||
|
||||
const newMetadata =
|
||||
params.merge !== false
|
||||
|
|
@ -12431,21 +12612,49 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
const metadataStats = await this.metadataIndex.getStats()
|
||||
const graphSize = await this.graphIndex.size()
|
||||
|
||||
// 1. Index size parity. HNSW must hold at least one node per indexed entity.
|
||||
if (hnswSize === metadataStats.totalEntries) {
|
||||
// 1. Index size parity. HNSW must hold one node per VECTORED noun — the
|
||||
// vectored-noun ledger (`getCanonicalCounts().vectors.all`), NOT the raw
|
||||
// metadata-entry count: every store's VFS root is PERMANENTLY unvectored
|
||||
// (`vector: []` by design — a zero-norm/empty vector never crosses into
|
||||
// the index, see AddToVectorIndexOperation/JsHnswVectorIndex.rebuild()'s
|
||||
// matching belts), and a not-yet-landed deferred embed is unvectored
|
||||
// too. Comparing against total entries counted the always-unvectored
|
||||
// root as a permanent 1-node "drift" on every VFS-having store — a false
|
||||
// warn on an otherwise perfectly healthy handoff. `vectors.all` is
|
||||
// already the documented coverage denominator for exactly this
|
||||
// comparison (see `CanonicalCounts.vectors`'s JSDoc). Falls back to the
|
||||
// metadata-entry count when the ledger is unavailable or suspect (a
|
||||
// storage adapter without the optional hook, or an unrecounted store) —
|
||||
// never worse than the prior behavior in that case.
|
||||
const vectorLedgerForParity = await this.storage.getCanonicalCounts?.()
|
||||
const vectorParityTarget =
|
||||
vectorLedgerForParity && !vectorLedgerForParity.suspect
|
||||
? vectorLedgerForParity.vectors.all
|
||||
: metadataStats.totalEntries
|
||||
if (hnswSize === vectorParityTarget) {
|
||||
checks.push({
|
||||
name: 'index-parity',
|
||||
status: 'pass',
|
||||
message: `HNSW (${hnswSize}) and metadata index (${metadataStats.totalEntries}) agree.`,
|
||||
details: { hnswSize, metadataEntries: metadataStats.totalEntries, graphRelationships: graphSize }
|
||||
message: `HNSW (${hnswSize}) and the vectored-noun ledger (${vectorParityTarget}) agree.`,
|
||||
details: {
|
||||
hnswSize,
|
||||
vectoredNouns: vectorParityTarget,
|
||||
metadataEntries: metadataStats.totalEntries,
|
||||
graphRelationships: graphSize
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const drift = Math.abs(hnswSize - metadataStats.totalEntries)
|
||||
const drift = Math.abs(hnswSize - vectorParityTarget)
|
||||
checks.push({
|
||||
name: 'index-parity',
|
||||
status: drift > Math.max(10, metadataStats.totalEntries * 0.01) ? 'fail' : 'warn',
|
||||
message: `HNSW (${hnswSize}) and metadata (${metadataStats.totalEntries}) differ by ${drift}. Run a rebuild if the gap is unexpected.`,
|
||||
details: { hnswSize, metadataEntries: metadataStats.totalEntries, drift }
|
||||
status: drift > Math.max(10, vectorParityTarget * 0.01) ? 'fail' : 'warn',
|
||||
message: `HNSW (${hnswSize}) and the vectored-noun ledger (${vectorParityTarget}) differ by ${drift}. Run a rebuild if the gap is unexpected.`,
|
||||
details: {
|
||||
hnswSize,
|
||||
vectoredNouns: vectorParityTarget,
|
||||
metadataEntries: metadataStats.totalEntries,
|
||||
drift
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -16066,6 +16275,79 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
return !this.pluginRegistry.hasProvider('embeddings')
|
||||
}
|
||||
|
||||
/**
|
||||
* @description LEG C of the zero-norm/unvector-door law — migrate a
|
||||
* legacy zero-norm VFS root BEFORE the vector-leg open gate
|
||||
* ({@link rebuildIndexesIfNeeded}'s `vectorCoverageGap` check) ever
|
||||
* compares the canonical vectored-noun count against the vector index's
|
||||
* size. A pre-fix store may have persisted the VFS root (the fixed
|
||||
* all-zeros UUID) with a REAL all-zero placeholder vector — lawful inside
|
||||
* brainy (`cosineDistance` treats a zero-norm operand as MAXIMUM distance,
|
||||
* see {@link isZeroNormVector}'s JSDoc) but never indexed (the index belt
|
||||
* refuses to insert a zero-norm vector) and never meant to cross an
|
||||
* engine boundary. Left unmigrated, the canonical ledger still counts it
|
||||
* as vectored while the vector index correctly holds nothing for it — a
|
||||
* near-empty store whose ONLY vectored row is this zero-norm root reads
|
||||
* "canonical vectored 1, index size 0" and throws
|
||||
* `VectorIndexNotReadyError` at open, going DARK instead of serving.
|
||||
*
|
||||
* THE LIFECYCLE LAW: nothing at open may scale with brain size. This step
|
||||
* is safe under that law BECAUSE the VFS root lives at a FIXED,
|
||||
* well-known id (`00000000-0000-0000-0000-000000000000` — mirrors
|
||||
* `VirtualFileSystem.VFS_ROOT_ID`; kept as a literal here, the same
|
||||
* convention as the other reserved-root literals in this file and in
|
||||
* `db/factLog.ts`/`db/portableGraph.ts` — `brainy.ts` cannot import
|
||||
* `VirtualFileSystem.ts`, which itself imports `Brainy`) — this is ONE
|
||||
* direct canonical read by id (`storage.getNoun`, the same O(1)
|
||||
* fixed-path lookup {@link unvectorNounForRootMigration} itself uses
|
||||
* internally), NEVER a listing or a walk over `entities/nouns/**`. An
|
||||
* absent root (a store that has never used the VFS) is a no-op, no error.
|
||||
*
|
||||
* Runs UNCONDITIONALLY at every open, independent of whether a
|
||||
* `VirtualFileSystem` is ever constructed this session — the vector-leg
|
||||
* gate this fixes runs during Brainy's OWN init, before any
|
||||
* `VirtualFileSystem` instance exists to run its own lazy migration at
|
||||
* `doInitializeRoot()` (kept in place as the second line of defense for a
|
||||
* VFS actually opened this session — belt AND suspenders, never either
|
||||
* alone).
|
||||
*/
|
||||
private async migrateLegacyZeroNormVfsRootIfNeeded(): Promise<void> {
|
||||
const VFS_ROOT_ID = '00000000-0000-0000-0000-000000000000'
|
||||
// TORN-TOLERANT: a torn root record is a recovery-walk healer's job
|
||||
// (see tests/integration/recovery-walk-tolerance.test.ts — an init-time
|
||||
// walk that meets a torn record narrates+counts, via the adapter's own
|
||||
// loud floor at the read site, and heals PAST it; the open itself must
|
||||
// still succeed), not this O(1) migration check's. Skip this open's
|
||||
// migration attempt rather than aborting init(): this leg is a
|
||||
// defensive EXTRA (the index belt + VirtualFileSystem's own
|
||||
// doInitializeRoot() migration still stand as the other lines of
|
||||
// defense), and it retries harmlessly at a later open once the root
|
||||
// heals.
|
||||
let root: HNSWNounWithMetadata | null
|
||||
try {
|
||||
root = await this.storage.getNoun(VFS_ROOT_ID)
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code !== 'TORN_RECORD') throw err
|
||||
prodLog.warn(
|
||||
`[Brainy] open(): the VFS root's record is TORN — skipping the zero-norm root ` +
|
||||
`migration check this open (the recovery walk is the healer; this migration ` +
|
||||
`retries harmlessly once the root heals).`
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!root || !Array.isArray(root.vector) || root.vector.length === 0) return
|
||||
if (!isZeroNormVector(root.vector)) return
|
||||
const migrated = await this.unvectorNounForRootMigration(VFS_ROOT_ID)
|
||||
if (migrated) {
|
||||
prodLog.warn(
|
||||
`[Brainy] open(): migrated the VFS root's legacy all-zero placeholder vector to ` +
|
||||
`the unvectored shape (zero-norm vectors never cross an engine boundary) — run ` +
|
||||
`before the vector-leg open gate compares canonical-vectored-count against the ` +
|
||||
`vector index, so a near-empty store never reads a false coverage gap.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SANCTIONED, ONE-TIME MIGRATION HOOK — rewrite a canonical noun's
|
||||
* persisted vector from a real (non-empty) vector to the "unvectored"
|
||||
|
|
@ -16075,13 +16357,18 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
* sanctioned {@link StorageAdapter.noteVectorUnlanded} hook — so the
|
||||
* coverage ledger never silently drifts.
|
||||
*
|
||||
* Exists SOLELY for the VFS root zero-norm migration (see
|
||||
* `VirtualFileSystem.doInitializeRoot()`, which detects a persisted root
|
||||
* whose vector is the legacy all-zero placeholder and calls this once per
|
||||
* store). This is NOT a general-purpose "clear my vector" API — ordinary
|
||||
* application data has no sanctioned path from vectored back to
|
||||
* unvectored (`update()` refuses an empty vector as a dimension mismatch,
|
||||
* by design). Never call this outside the VFS root migration.
|
||||
* Exists SOLELY for the VFS root zero-norm migration, called from two
|
||||
* sites that detect the same legacy shape (a persisted root whose vector
|
||||
* is the legacy all-zero placeholder): {@link migrateLegacyZeroNormVfsRootIfNeeded}
|
||||
* (this brain's own init sequence, BEFORE the vector-leg open gate — Leg
|
||||
* C of the zero-norm/unvector-door law) and
|
||||
* `VirtualFileSystem.doInitializeRoot()` (the second line of defense, for
|
||||
* a VFS actually constructed this session). This is NOT the general-
|
||||
* purpose unvector API — ordinary application data uses the sanctioned
|
||||
* unvector DOOR instead (`update({ id, vector: [] })` / the same op inside
|
||||
* `transact()`), which decrements the ledger and clears any pending
|
||||
* deferred-embed marker inline; it does not call this method. Never call
|
||||
* this outside a VFS root migration.
|
||||
*
|
||||
* Idempotent: a noun already unvectored (`vector.length === 0`) or absent
|
||||
* is a no-op — safe to call on every `init()`.
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
Vector,
|
||||
VectorDocument
|
||||
} from '../coreTypes.js'
|
||||
import { euclideanDistance, calculateDistancesBatch } from '../utils/index.js'
|
||||
import { euclideanDistance, calculateDistancesBatch, isZeroNormVector } from '../utils/index.js'
|
||||
import type { BaseStorage } from '../storage/baseStorage.js'
|
||||
import { getGlobalCache, UnifiedCache } from '../utils/unifiedCache.js'
|
||||
import { prodLog } from '../utils/logger.js'
|
||||
|
|
@ -1834,10 +1834,24 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
|
|||
// Process all nouns at once
|
||||
for (const nounData of result.items) {
|
||||
try {
|
||||
if (!nounData.vector || nounData.vector.length === 0) {
|
||||
if (!Array.isArray(nounData.vector) || nounData.vector.length === 0) {
|
||||
skippedUnvectored++
|
||||
continue
|
||||
}
|
||||
// THE ZERO-NORM LAW — bulk-rebuild leg: a persisted zero-norm
|
||||
// vector (a pre-10.4.2 row the canonical write has not yet
|
||||
// normalized) must never enter the index either, mirroring the
|
||||
// belt AddToVectorIndexOperation enforces on the live write path.
|
||||
// Only the canonical vector is authoritative here — persisted
|
||||
// HNSW graph metadata (level/connections) can outlive an unvector.
|
||||
if (isZeroNormVector(nounData.vector)) {
|
||||
prodLog.warn(
|
||||
`[HNSW] rebuild(): skipping entity ${nounData.id} — persisted vector is ` +
|
||||
`zero-norm (a zero-norm vector is not a vector and never crosses an ` +
|
||||
`engine boundary)`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Restore the pinned dimension from the first real vector this
|
||||
// rebuild loads. `addItem`/`updateItem` only pin `this.dimension`
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
import { getBrainyVersion } from '../../utils/index.js'
|
||||
import { isAbsentError } from '../../utils/errorClassification.js'
|
||||
import { prodLog } from '../../utils/logger.js'
|
||||
import { isZeroNormVector } from '../../utils/distance.js'
|
||||
import {
|
||||
TornRecordError,
|
||||
isUnparseablePayloadError,
|
||||
|
|
@ -2841,14 +2842,20 @@ export class FileSystemStorage extends BaseStorage {
|
|||
}
|
||||
|
||||
/**
|
||||
* Count canonical nouns holding a REAL (non-empty) vector — the vectored-
|
||||
* noun ledger scalar. UNLIKE {@link scanCanonicalEntities}, presence
|
||||
* cannot be decided from the id-directory listing alone: a deferred-embed
|
||||
* noun's `vectors.json` EXISTS (written at `add()` time with `vector: []`)
|
||||
* until its embed LANDS, so this walk reads every noun's `vectors.json`
|
||||
* CONTENT — O(nouns) reads, not O(ids) listing. Used ONLY for a one-time
|
||||
* legacy-counts.json derivation or a lost/corrupted counts.json recovery;
|
||||
* the result is persisted so this scan never repeats.
|
||||
* Count canonical nouns holding a REAL (non-empty, non-zero-norm) vector —
|
||||
* the vectored-noun ledger scalar. UNLIKE {@link scanCanonicalEntities},
|
||||
* presence cannot be decided from the id-directory listing alone: a
|
||||
* deferred-embed noun's `vectors.json` EXISTS (written at `add()` time
|
||||
* with `vector: []`) until its embed LANDS, so this walk reads every
|
||||
* noun's `vectors.json` CONTENT — O(nouns) reads, not O(ids) listing.
|
||||
* ZERO-NORM LAW: a real all-zero vector is not a vector — it never counts
|
||||
* here either (Brainy's write paths normalize an explicit zero-norm
|
||||
* vector to `[]` at write time, but a store created before that fix may
|
||||
* still carry legacy all-zero rows on disk; this derivation must agree
|
||||
* with the live ledger's definition of "vectored" regardless of when the
|
||||
* row was written). Used ONLY for a one-time legacy-counts.json derivation
|
||||
* or a lost/corrupted counts.json recovery; the result is persisted so
|
||||
* this scan never repeats.
|
||||
*/
|
||||
private async scanVectoredNounCount(): Promise<number> {
|
||||
const base = path.join(this.rootDir, 'entities', 'nouns')
|
||||
|
|
@ -2862,7 +2869,12 @@ export class FileSystemStorage extends BaseStorage {
|
|||
for (const entry of ids) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const record = await this.readEntityVectorRaw(path.join(shardPath, entry.name))
|
||||
if (record && Array.isArray(record.vector) && record.vector.length > 0) {
|
||||
if (
|
||||
record &&
|
||||
Array.isArray(record.vector) &&
|
||||
record.vector.length > 0 &&
|
||||
!isZeroNormVector(record.vector)
|
||||
) {
|
||||
vectored++
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -613,6 +613,17 @@ export function validateUpdateParams(params: UpdateParams): void {
|
|||
// null/undefined means "no new data was given".
|
||||
const hasData = params.data !== undefined && params.data !== null
|
||||
if ((params as UpdateParams & { deferEmbedding?: boolean }).deferEmbedding === true) {
|
||||
if (params.vector && params.vector.length === 0) {
|
||||
// The nonsensical combination Leg D of the zero-norm/unvector-door law
|
||||
// refuses: `vector: []` is the SANCTIONED UNVECTOR DOOR — an explicit
|
||||
// instruction to remove the vector NOW, never "please embed" — so it
|
||||
// cannot be paired with a request to defer an embed.
|
||||
throw new Error(
|
||||
`update(): 'vector: []' (the unvector door) cannot be combined with ` +
|
||||
`'deferEmbedding: true' — an unvector is an explicit instruction to remove ` +
|
||||
`the vector now, not a request to defer an embed. Drop one of the two.`
|
||||
)
|
||||
}
|
||||
if (params.vector) {
|
||||
throw new Error(
|
||||
`update(): deferEmbedding cannot be combined with an explicit 'vector' — ` +
|
||||
|
|
@ -649,8 +660,16 @@ export function validateUpdateParams(params: UpdateParams): void {
|
|||
throw new Error(`invalid NounType: ${params.type}`)
|
||||
}
|
||||
|
||||
// Validate vector dimensions if provided
|
||||
if (params.vector) {
|
||||
// Validate vector dimensions if provided. A length-0 vector is the
|
||||
// SANCTIONED UNVECTOR DOOR (see brainy.ts update()'s matching comment): an
|
||||
// explicit `vector: []` — or a real all-zero vector, normalized to `[]`
|
||||
// upstream by the zero-norm law — carries no dimension information,
|
||||
// exactly like validateAddParams's identical exemption, so it is exempt
|
||||
// from the dimension check rather than refused as a "0-dimensional
|
||||
// vector". (The `deferEmbedding` combination above already refuses
|
||||
// `vector: []` paired with `deferEmbedding: true` — an empty array is
|
||||
// truthy, so that guard fires unconditionally on any explicit `vector`.)
|
||||
if (params.vector && params.vector.length > 0) {
|
||||
const config = ValidationConfig.getInstance()
|
||||
if (params.vector.length !== config.maxVectorDimensions) {
|
||||
throw new Error(`vector must have exactly ${config.maxVectorDimensions} dimensions`)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue