Merge branch 'next/zero-norm-unvector-door'
All checks were successful
CI / Node 22 (push) Successful in 12m24s
CI / Node 24 (push) Successful in 12m6s
CI / Bun (latest) (push) Successful in 12m32s
CI / Integration + conformance (Node 22) (push) Successful in 19m41s

# Conflicts:
#	src/hnsw/hnswIndex.ts
This commit is contained in:
David Snelling 2026-08-27 13:54:06 -07:00
commit 9b84ef5b02
6 changed files with 798 additions and 54 deletions

View file

@ -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()`.

View file

@ -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`

View file

@ -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++
}
}

View file

@ -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`)