fix(vfs): the VFS root never persists a zero-norm vector
Some checks failed
CI / Node 22 (push) Successful in 12m23s
CI / Node 24 (push) Successful in 12m12s
CI / Integration + conformance (Node 22) (push) Failing after 14m47s
CI / Bun (latest) (push) Successful in 12m38s

A zero-norm vector is lawful inside brainy (cosine distance scores it at
maximum, never a false top hit) but a false attractor for a downstream
engine serving squared-euclidean distance, which cannot tell a real
all-zero vector apart from a legitimate origin point.

- The VFS root now persists with vector [] (the existing "unvectored"
  shape) instead of a real all-zero 384-dim placeholder, and is never
  routed into the deferred-embed pipeline.
- A one-time migration in the root-init path detects a pre-fix store's
  all-zero placeholder root (by norm, not length) and rewrites it to []
  through a new sanctioned Brainy method that keeps the canonical
  vectored-noun ledger honest and removes the row from the vector index.
- The vector-index write seam (AddToVectorIndexOperation,
  ReplaceInVectorIndexOperation, and the generation materializer's direct
  insert) now refuses any real all-zero vector before it reaches a
  provider, loudly naming the entity, while the canonical write still
  lands.
- add()'s dimension-pinning and HNSW-insert gates, and the add-params
  validator, now treat any empty vector as carrying no dimension
  information, closing a latent trap where an explicit `vector: []`
  would have pinned dimensions to 0.
This commit is contained in:
David Snelling 2026-08-27 09:28:44 -07:00
parent aad9e2eeb1
commit c6cc0de955
10 changed files with 516 additions and 64 deletions

View file

@ -25,6 +25,7 @@ import {
} from './storage/brainFormat.js' } from './storage/brainFormat.js'
import type { BrainFormat } from './storage/brainFormat.js' import type { BrainFormat } from './storage/brainFormat.js'
import { StorageAdapter, Vector, DistanceFunction, EmbeddingFunction, GraphVerb, STANDARD_ENTITY_FIELDS } from './coreTypes.js' import { StorageAdapter, Vector, DistanceFunction, EmbeddingFunction, GraphVerb, STANDARD_ENTITY_FIELDS } from './coreTypes.js'
import { isZeroNormVector } from './utils/distance.js'
import type { HNSWNoun, HNSWNounWithMetadata, HNSWVerbWithMetadata, EntityVisibility } from './coreTypes.js' import type { HNSWNoun, HNSWNounWithMetadata, HNSWVerbWithMetadata, EntityVisibility } from './coreTypes.js'
import { import {
defaultEmbeddingFunction, defaultEmbeddingFunction,
@ -2913,7 +2914,13 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// Ensure dimensions are set (a deferred-embed stub carries no dimension // Ensure dimensions are set (a deferred-embed stub carries no dimension
// information — the worker's real vector goes through the same guard). // information — the worker's real vector goes through the same guard).
if (!deferringEmbed) { // Gated on `vector.length > 0`, not `!deferringEmbed`: ANY insert whose
// vector is the "unvectored" empty-array shape carries no dimension
// information, deferred or not — an explicit `vector: []` (e.g. the VFS
// root's zero-norm fix, see VirtualFileSystem.doInitializeRoot()) must
// never pin `this.dimensions` to 0, which would poison every subsequent
// real embed's dimension check for the life of the store.
if (!deferringEmbed && vector.length > 0) {
if (!this.dimensions) { if (!this.dimensions) {
this.dimensions = vector.length this.dimensions = vector.length
} else if (vector.length !== this.dimensions) { } else if (vector.length !== this.dimensions) {
@ -3029,10 +3036,15 @@ export class Brainy<T = any> implements BrainyInterface<T> {
}, true) }, true)
) )
// Operation 3: Add to HNSW index (after entity saved). A deferred // Operation 3: Add to HNSW index (after entity saved). Gated on
// embed has nothing to index yet — the worker's atomic update // `vector.length > 0`, not `!deferringEmbed`: a deferred embed has
// inserts the real vector. // nothing to index yet (the worker's atomic update inserts the real
if (!deferringEmbed) { // vector later), and an explicit `vector: []` insert (the VFS root's
// zero-norm fix — permanently unvectored plumbing, never embedded)
// is exactly the same "nothing to index yet" shape. The zero-norm
// BELT (a real all-zero vector, non-empty) is enforced inside
// AddToVectorIndexOperation itself — see its JSDoc.
if (vector.length > 0) {
tx.addOperation( tx.addOperation(
new AddToVectorIndexOperation(this.index, id, vector, this.indexWriteGeneration) new AddToVectorIndexOperation(this.index, id, vector, this.indexWriteGeneration)
) )
@ -10245,6 +10257,18 @@ export class Brainy<T = any> implements BrainyInterface<T> {
for (const id of nounIds) { for (const id of nounIds) {
const noun = await snapshotStorage.getNoun(id) const noun = await snapshotStorage.getNoun(id)
if (noun && Array.isArray(noun.vector) && noun.vector.length > 0) { if (noun && Array.isArray(noun.vector) && noun.vector.length > 0) {
// THE ZERO-NORM LAW: a direct provider-write seam (this materializer
// inserts one-by-one, bypassing AddToVectorIndexOperation's own
// belt) — apply the same refusal here rather than handing a false
// attractor to the ephemeral reader's index.
if (isZeroNormVector(noun.vector)) {
prodLog.warn(
`[Brainy] materializeAtGeneration: refusing to index a zero-norm vector for ` +
`entity ${noun.id} — a zero-norm vector is not a vector and never crosses an ` +
`engine boundary (the materialized record is unaffected)`
)
continue
}
await reader.index.addItem({ id: noun.id, vector: noun.vector }) await reader.index.addItem({ id: noun.id, vector: noun.vector })
} }
} }
@ -10519,7 +10543,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
const vector = deferringEmbed const vector = deferringEmbed
? [] ? []
: params.vector || (await this.embed(params.data)) : params.vector || (await this.embed(params.data))
if (!deferringEmbed) { // 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.
if (!deferringEmbed && vector.length > 0) {
if (!this.dimensions) { if (!this.dimensions) {
this.dimensions = vector.length this.dimensions = vector.length
} else if (vector.length !== this.dimensions) { } else if (vector.length !== this.dimensions) {
@ -10601,9 +10628,12 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// for a deferred embed (stub vector `[]`; counted later at landing). // for a deferred embed (stub vector `[]`; counted later at landing).
new SaveNounMetadataOperation(this.storage, id, storageMetadata, isNew, vector.length > 0), new SaveNounMetadataOperation(this.storage, id, storageMetadata, isNew, vector.length > 0),
new SaveNounOperation(this.storage, { id, vector, connections: new Map(), level: 0 }, isNew), new SaveNounOperation(this.storage, { id, vector, connections: new Map(), level: 0 }, isNew),
...(deferringEmbed // Gated on `vector.length > 0` — see the single-add() insert path's
? [] // matching comment: an explicit `vector: []` has nothing to index
: [new AddToVectorIndexOperation(this.index, id, vector, this.indexWriteGeneration)]), // either, deferred or not.
...(vector.length > 0
? [new AddToVectorIndexOperation(this.index, id, vector, this.indexWriteGeneration)]
: []),
new AddToMetadataIndexOperation(this.metadataIndex, id, entityForIndexing, this.indexWriteGeneration) new AddToMetadataIndexOperation(this.metadataIndex, id, entityForIndexing, this.indexWriteGeneration)
) )
plan.touchedNouns.push(id) plan.touchedNouns.push(id)
@ -16032,6 +16062,63 @@ export class Brainy<T = any> implements BrainyInterface<T> {
return !this.pluginRegistry.hasProvider('embeddings') return !this.pluginRegistry.hasProvider('embeddings')
} }
/**
* SANCTIONED, ONE-TIME MIGRATION HOOK rewrite a canonical noun's
* persisted vector from a real (non-empty) vector to the "unvectored"
* empty-array shape: the vector record is rewritten to `[]`, the row is
* removed from the vector index (if present), and the vectored-noun
* ledger (`getCanonicalCounts().vectors.all`) is decremented through the
* 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.
*
* Idempotent: a noun already unvectored (`vector.length === 0`) or absent
* is a no-op safe to call on every `init()`.
*
* @param id - The canonical noun id to migrate.
* @returns `true` if a migration write happened, `false` if the noun was
* already unvectored (or absent) a no-op.
*/
async unvectorNounForRootMigration(id: string): Promise<boolean> {
const noun = await this.storage.getNoun(id)
if (!noun || !Array.isArray(noun.vector) || noun.vector.length === 0) return false
await this.persistSingleOp({ nouns: [id] }, async (tx) => {
// Rewrite the vector leg to the unvectored shape. Placeholder adjacency
// (mirrors update()'s own SaveNounOperation staging) — the op preserves
// stored graph state when `connections.size === 0`.
tx.addOperation(
new SaveNounOperation(this.storage, {
id,
vector: [],
connections: new Map(),
level: 0
})
)
// Remove from the vector index — safe even if the row was never
// actually indexed (RemoveFromVectorIndexOperation's removeItem is a
// no-op when the id is absent).
tx.addOperation(
new RemoveFromVectorIndexOperation(this.index, id, noun.vector, this.indexWriteGeneration)
)
})
// Vectored-noun ledger: this migration carries a vector write with no
// accompanying metadata operation (metadata is untouched), so the
// saveNounMetadata(..., hasVector) seam never fires for it — mirrors the
// deferred-embed LANDING path's use of the narrow storage hook, in
// reverse.
await this.storage.noteVectorUnlanded?.(id)
return true
}
/** /**
* Setup embedder * Setup embedder
*/ */

View file

@ -872,6 +872,27 @@ export interface StorageAdapter {
*/ */
noteVectorLanded?(id: string): Promise<void> noteVectorLanded?(id: string): Promise<void>
/**
* OPTIONAL narrow ledger hook, the mirror of {@link noteVectorLanded}:
* record that a canonical noun's vector was just REMOVED rewritten from
* a real (non-empty) vector to the "unvectored" empty-array shape. Exists
* for the ONE sanctioned reverse migration this engine supports: the VFS
* root's zero-norm fix (see `VirtualFileSystem.doInitializeRoot()` and
* `Brainy.unvectorNounForRootMigration()`), which rewrites a pre-fix
* store's all-zero placeholder root vector to `[]` and must decrement
* `vectors.all` through this hook so the coverage ledger never drifts.
* NOT a general-purpose "I removed a vector" callback ordinary
* application data has no sanctioned path from vectored back to
* unvectored (`update()` refuses an empty vector as a dimension
* mismatch by design). Callers MUST call this only when the noun held a
* REAL vector immediately before this write (the caller already holds
* that fact for free, from its own pre-write read never an added read).
* A backend without vectored-noun tracking is a no-op via this method's
* absence (feature-detected).
* @param id - The noun whose vector was just removed.
*/
noteVectorUnlanded?(id: string): Promise<void>
/** /**
* Get noun with metadata combined * Get noun with metadata combined
* @returns Combined HNSWNounWithMetadata or null * @returns Combined HNSWNounWithMetadata or null

View file

@ -1152,6 +1152,24 @@ export abstract class BaseStorageAdapter implements StorageAdapter {
}) })
} }
/**
* OPTIONAL narrow ledger hook (see {@link StorageAdapter.noteVectorUnlanded}):
* the mirror of {@link noteVectorLanded} record a noun's vector was just
* REMOVED (rewritten to the unvectored `[]` shape). Never below zero: a
* caller that (incorrectly) fires this for a noun already unvectored would
* otherwise drive the ledger negative clamped defensively, matching the
* delete path's `if (this.totalVectoredNounCount > 0)` guard.
* @param id - The noun whose vector was just removed (retained for a
* future narration seam; the count itself needs no id-keyed state).
*/
async noteVectorUnlanded(id: string): Promise<void> {
void id
if (this.totalVectoredNounCount > 0) this.totalVectoredNounCount--
this.scheduleCountPersist().catch(() => {
// Ignore persist errors — the in-memory count is authoritative; a later op retries.
})
}
/** /**
* Increment count for entity type - O(1) operation. * Increment count for entity type - O(1) operation.
* Concurrency is handled by the process-global mutex * Concurrency is handled by the process-global mutex

View file

@ -13,6 +13,8 @@ import type { VectorIndexProvider, GraphIndexProvider } from '../../plugin.js'
import type { MetadataIndexManager } from '../../utils/metadataIndex.js' import type { MetadataIndexManager } from '../../utils/metadataIndex.js'
import type { GraphVerb } from '../../coreTypes.js' import type { GraphVerb } from '../../coreTypes.js'
import type { Operation, RollbackAction } from '../types.js' import type { Operation, RollbackAction } from '../types.js'
import { isZeroNormVector } from '../../utils/distance.js'
import { prodLog } from '../../utils/logger.js'
/** /**
* Backend identity stamped into an operation's emitted `name` string (e.g. * Backend identity stamped into an operation's emitted `name` string (e.g.
@ -88,6 +90,30 @@ export class AddToVectorIndexOperation implements Operation {
} }
async execute(): Promise<RollbackAction> { async execute(): Promise<RollbackAction> {
// THE ZERO-NORM LAW (the live provider-write seam's belt): a zero-norm
// vector is not a vector — it never crosses an engine boundary. This
// engine's own cosine distance treats an all-zero vector safely (a
// zero-norm operand always scores MAXIMUM distance, see
// {@link 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 darkens real results.
// The canonical write already landed (SaveNoun/SaveNounMetadata
// operations are staged ahead of this one in every caller) — only the
// INDEX INSERT is refused here, loudly, never a throw. A length-0
// vector is the unrelated "unvectored" shape and is skipped silently
// (the same contract callers already rely on for deferred embeds).
if (this.vector.length === 0) {
return async () => {}
}
if (isZeroNormVector(this.vector)) {
prodLog.warn(
`[vector-index] refusing to index a zero-norm vector for entity ${this.id}` +
`a zero-norm vector is not a vector and never crosses an engine boundary ` +
`(the canonical write is unaffected; only the vector-index insert is skipped)`
)
return async () => {}
}
// Check if item already exists (for rollback decision) // Check if item already exists (for rollback decision)
const existed = await this.itemExists(this.id) const existed = await this.itemExists(this.id)
@ -263,6 +289,35 @@ export class ReplaceInVectorIndexOperation implements Operation {
// One commit generation for the whole replace (both branches + rollback). // One commit generation for the whole replace (both branches + rollback).
const generation = this.generationFn?.() const generation = this.generationFn?.()
// THE ZERO-NORM LAW (see AddToVectorIndexOperation's matching JSDoc): a
// real all-zero replacement vector must never land in the index — refuse
// loudly, canonical write unaffected. The row must not be left stale
// either: if it was genuinely indexed under `oldVector`, remove it
// rather than pretend the old vector still describes the row. A
// length-0 `newVector` (the unrelated "unvectored" shape) is handled the
// same way, silently — no caller today reaches this with an empty
// replacement (update() rejects a dimension-mismatched empty vector),
// but the seam stays consistent in case one ever legitimately does.
if (isZeroNormVector(this.newVector) || this.newVector.length === 0) {
const wasIndexed = this.oldVector.length > 0 && !isZeroNormVector(this.oldVector)
if (isZeroNormVector(this.newVector)) {
prodLog.warn(
`[vector-index] refusing to replace with a zero-norm vector for entity ${this.id}` +
`a zero-norm vector is not a vector and never crosses an engine boundary ` +
`(the canonical write is unaffected; the row is removed from the vector index instead)`
)
}
if (wasIndexed) {
await this.index.removeItem(this.id, generation)
}
return async () => {
// Restore the declared before-state.
if (wasIndexed) {
await this.index.addItem({ id: this.id, vector: this.oldVector }, generation)
}
}
}
if (typeof index.updateItem === 'function') { if (typeof index.updateItem === 'function') {
// Atomic path: one in-place call, the row never leaves the index. // Atomic path: one in-place call, the row never leaves the index.
await index.updateItem({ id: this.id, vector: this.newVector }, generation) await index.updateItem({ id: this.id, vector: this.newVector }, generation)

View file

@ -65,6 +65,29 @@ export const cosineDistance: DistanceFunction = (a: Vector, b: Vector): number =
return 1 - similarity return 1 - similarity
} }
/**
* True when `vector` is a REAL (non-empty) all-zero vector the "false
* attractor" shape this engine's own cosine distance treats safely (a
* zero-norm operand always scores the MAXIMUM distance, see
* {@link cosineDistance}) but a downstream engine serving squared-euclidean
* distance cannot distinguish from a legitimate origin point. THE LAW: a
* zero-norm vector is not a vector it never crosses an engine boundary
* (never handed to a vector-index provider as a searchable item).
*
* A length-0 vector is the UNRELATED "unvectored, not yet embedded" shape
* (the deferred-embed stub, a permanently-vectorless system row) and is
* deliberately NOT zero-norm here callers checking for "nothing to index"
* should test `vector.length === 0` separately; this only flags the
* dangerous non-empty all-zero case.
*/
export function isZeroNormVector(vector: readonly number[]): boolean {
if (vector.length === 0) return false
for (let i = 0; i < vector.length; i++) {
if (vector[i] !== 0) return false
}
return true
}
/** /**
* Calculates the Manhattan (L1) distance between two vectors. * Calculates the Manhattan (L1) distance between two vectors.
* Lower values indicate higher similarity. * Lower values indicate higher similarity.

View file

@ -588,8 +588,14 @@ export function validateAddParams(params: AddParams): void {
) )
} }
// Validate vector dimensions if provided // Validate vector dimensions if provided. A length-0 vector is the
if (params.vector) { // "unvectored" shape — an explicit `vector: []` (e.g. the VFS root's
// permanently-vectorless creation, see
// VirtualFileSystem.doInitializeRoot()'s zero-norm fix) carries no
// dimension information, exactly like an absent vector or a deferred
// embed's internal stub, so it is exempt from the dimension check rather
// than refused as a "0-dimensional vector".
if (params.vector && params.vector.length > 0) {
const config = ValidationConfig.getInstance() const config = ValidationConfig.getInstance()
if (params.vector.length !== config.maxVectorDimensions) { if (params.vector.length !== config.maxVectorDimensions) {
throw new Error(`vector must have exactly ${config.maxVectorDimensions} dimensions`) throw new Error(`vector must have exactly ${config.maxVectorDimensions} dimensions`)

View file

@ -11,6 +11,7 @@ import { v4 as uuidv4 } from '../universal/uuid.js'
import { Brainy } from '../brainy.js' import { Brainy } from '../brainy.js'
import { Entity, AddParams, RelateParams, FindParams, Relation } from '../types/brainy.types.js' import { Entity, AddParams, RelateParams, FindParams, Relation } from '../types/brainy.types.js'
import { NounType, VerbType } from '../types/graphTypes.js' import { NounType, VerbType } from '../types/graphTypes.js'
import { isZeroNormVector } from '../utils/distance.js'
import { PathResolver } from './PathResolver.js' import { PathResolver } from './PathResolver.js'
import { mimeDetector } from './MimeTypeDetector.js' import { mimeDetector } from './MimeTypeDetector.js'
import { import {
@ -89,16 +90,6 @@ export class VirtualFileSystem implements IVirtualFileSystem {
// Uses deterministic UUID format for storage compatibility // Uses deterministic UUID format for storage compatibility
private static readonly VFS_ROOT_ID = '00000000-0000-0000-0000-000000000000' private static readonly VFS_ROOT_ID = '00000000-0000-0000-0000-000000000000'
// OPEN-PATH FIX: the dimension of the placeholder vector given to the VFS
// root when it is first created (see `doInitializeRoot`). Mirrors the
// built-in WASM embedding engine's fixed, hardcoded output size
// (all-MiniLM-L6-v2 — see src/embeddings/candle-wasm/src/lib.rs
// HIDDEN_SIZE and src/embeddings/EmbeddingManager.ts). Deliberately NOT
// derived from `brain.dimensions` — this constant only applies to the
// default-embedder branch, where the true output dimension is this fixed
// value by construction, never a moving target.
private static readonly VFS_ROOT_VECTOR_DIMENSIONS = 384
/** /**
* Construct a VFS bound to a Brainy instance. * Construct a VFS bound to a Brainy instance.
* *
@ -241,9 +232,11 @@ export class VirtualFileSystem implements IVirtualFileSystem {
private async doInitializeRoot(): Promise<string> { private async doInitializeRoot(): Promise<string> {
const rootId = VirtualFileSystem.VFS_ROOT_ID const rootId = VirtualFileSystem.VFS_ROOT_ID
// Try to get existing root by fixed ID (O(1) lookup, not query) // Try to get existing root by fixed ID (O(1) lookup, not query).
// includeVectors: true — the zero-norm migration below (leg 2) needs to
// inspect the persisted vector to detect the legacy placeholder shape.
try { try {
const existingRoot = await this.brain.get(rootId) const existingRoot = await this.brain.get(rootId, { includeVectors: true })
if (existingRoot) { if (existingRoot) {
// Root exists - verify metadata is correct // Root exists - verify metadata is correct
@ -260,6 +253,34 @@ export class VirtualFileSystem implements IVirtualFileSystem {
}) })
} }
// ZERO-NORM ROOT MIGRATION (one-time): a pre-fix store persisted the
// root with a REAL all-zero placeholder vector — lawful inside
// brainy (cosineDistance treats a zero-norm operand as MAXIMUM
// distance, see src/utils/distance.ts) but a "false attractor" for a
// downstream engine serving squared-euclidean distance, which cannot
// tell an all-zero vector apart from a legitimate origin point (a
// production incident silently darkened 150+ rows in a partner
// engine's index this way). THE LAW: a zero-norm vector is not a
// vector — it never crosses an engine boundary. Detect the legacy
// shape via NORM, not length or dimension (any real all-zero vector
// qualifies, not just the historical 384-dim one), and rewrite it to
// the "unvectored" `[]` shape through the sanctioned migration path
// (Brainy.unvectorNounForRootMigration — see its JSDoc), which keeps
// `getCanonicalCounts().vectors.all` honest and removes the row from
// the vector index. Idempotent: a store already on the new shape
// (vector.length === 0) takes the false branch below on every
// subsequent init() — a permanent no-op, not a one-time flag.
const existingVector = existingRoot.vector ?? []
if (existingVector.length > 0 && isZeroNormVector(existingVector)) {
const migrated = await this.brain.unvectorNounForRootMigration(rootId)
if (migrated) {
console.log(
'VFS: migrated root vector from the legacy all-zero placeholder to the ' +
'unvectored shape (zero-norm vectors never cross an engine boundary)'
)
}
}
return rootId return rootId
} }
} catch (error) { } catch (error) {
@ -277,31 +298,42 @@ export class VirtualFileSystem implements IVirtualFileSystem {
// meant every writer's FIRST-EVER open forced the process-global WASM // meant every writer's FIRST-EVER open forced the process-global WASM
// engine to cold-compile its model (measured 90-140s on throttled // engine to cold-compile its model (measured 90-140s on throttled
// CPUs) before the brain could even finish init(). This branch only // CPUs) before the brain could even finish init(). This branch only
// runs once per store (the root already exists — with a real vector // runs once per store (a previously-opened store already has a root
// in every previously-opened production store; reopening never re-adds // see the migration above for the pre-fix shape — reopening never
// it), so the fix applies only to brand-new stores. // re-adds it), so the fix applies only to brand-new stores.
// //
// Chosen fix: an explicit all-zero vector, not `deferEmbedding: true`. // ZERO-NORM LAW (current shape, superseding the historical all-zero
// `deferEmbedding` looked attractive (ack now, embed later) but its // placeholder): the root's vector is `[]` — the SAME "unvectored"
// landing path (`kickEmbedWorker()`, called synchronously right after // empty-array shape used for a deferred embed's stub and any other
// commit — see brainy.ts add()/update()) would still force the WASM // not-yet-embedded row — never a real all-zero vector. A zero-norm
// engine to cold-compile within milliseconds of open, just off the // vector is lawful inside brainy (`cosineDistance`, see
// awaited path instead of never paying it at all — worse than the // src/utils/distance.ts, returns the MAXIMUM distance whenever either
// explicit-vector path, which never touches the engine for this row. // operand's norm is zero) but is a "false attractor" for a downstream
// An all-zero vector is safe: `cosineDistance` (src/utils/distance.ts) // engine serving squared-euclidean distance, which cannot tell a real
// explicitly returns the MAXIMUM distance whenever either operand's // all-zero vector apart from a legitimate origin point — it silently
// norm is zero, so the root can never rank ahead of real content in a // darkened 150+ rows in a partner engine's index in production. THE
// similarity search, and HNSW indexes it like any other vector. // LAW: a zero-norm vector is not a vector — it never crosses an engine
// boundary. `vector: []` achieves the SAME cold-compile avoidance the
// original placeholder did (`add()`'s dimension-pin and HNSW-insert
// gates both key off `vector.length > 0`, so an empty vector never
// calls embed(), never pins `brain.dimensions`, and never reaches the
// vector index — see brainy.ts add()'s matching comments) while never
// persisting a searchable zero vector for a downstream engine to trip
// over. Deliberately NOT `deferEmbedding: true`: that flag's landing
// path (`kickEmbedWorker()`, called synchronously right after commit —
// see brainy.ts add()/update()) would still force the WASM engine to
// cold-compile within milliseconds of open (just off the awaited path
// instead of never paying it at all) AND would eventually embed the
// root's data for real, which this fix forbids — the root must NEVER
// be embedded, not merely "not yet".
// //
// Only the default WASM engine gets this treatment — its output // Only the default WASM engine gets this treatment — a plugin-
// dimension (384) is fixed and hardcoded, so the placeholder can never // registered native 'embeddings' provider has no cold-compile cost and
// mis-pin `brain.dimensions` for it. A plugin-registered native // may use a different dimension, so it keeps embedding the root for
// 'embeddings' provider has no cold-compile cost AND may use a // real (same as before this fix) rather than leave Brainy's own
// different dimension, so it keeps embedding the root for real (same // plumbing permanently unvectored on a store where embedding is cheap.
// as before this fix) rather than risk pinning the wrong dimension
// ahead of the caller's own first real embed.
const rootVector = this.brain.usesDefaultWasmEmbedder() const rootVector = this.brain.usesDefaultWasmEmbedder()
? new Array(VirtualFileSystem.VFS_ROOT_VECTOR_DIMENSIONS).fill(0) ? ([] as number[])
: undefined : undefined
await this.brain.add({ await this.brain.add({

View file

@ -225,9 +225,12 @@ describe('canonical count ledger — the vectored-noun scalar (the vector leg\'s
} }
/** Baseline vectored count right after a fresh open() init() creates a /** Baseline vectored count right after a fresh open() init() creates a
* hidden system VFS-root noun that itself carries a real vector, so a * hidden system VFS-root noun, but (the zero-norm root cure) it is
* brand-new store's `vectors.all` is 1, not 0. Tests assert DELTAS off * deliberately UNVECTORED (`vector: []`, never a real all-zero
* this baseline rather than hardcoding it away. */ * placeholder a zero-norm vector never crosses an engine boundary), so
* a brand-new store's `vectors.all` is 0. Tests still assert DELTAS off
* this baseline rather than hardcoding it away, in case that ever
* changes again. */
let baseline: number let baseline: number
beforeEach(async () => { beforeEach(async () => {
@ -235,6 +238,7 @@ describe('canonical count ledger — the vectored-noun scalar (the vector leg\'s
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-vectored-ledger-')) dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-vectored-ledger-'))
brain = await open() brain = await open()
baseline = (await brain.storage.getCanonicalCounts()).vectors.all baseline = (await brain.storage.getCanonicalCounts()).vectors.all
expect(baseline).toBe(0) // the unvectored VFS root contributes nothing
}) })
afterEach(async () => { afterEach(async () => {
vi.restoreAllMocks() vi.restoreAllMocks()
@ -348,7 +352,7 @@ describe('canonical count ledger — the vectored-noun scalar (the vector leg\'s
brain = await open() brain = await open()
const ledger = await brain.storage.getCanonicalCounts() const ledger = await brain.storage.getCanonicalCounts()
expect(ledger.vectors.all).toBe(baseline + 1) // the root + the one non-deferred noun expect(ledger.vectors.all).toBe(baseline + 1) // just the one non-deferred noun — the root is unvectored
expect(ledger.vectors.all).toBe(countVectoredNouns(dir)) expect(ledger.vectors.all).toBe(countVectoredNouns(dir))
const persisted = JSON.parse(fs.readFileSync(countsPath, 'utf-8')) const persisted = JSON.parse(fs.readFileSync(countsPath, 'utf-8'))
expect(persisted.totalVectoredNounCount).toBe(baseline + 1) expect(persisted.totalVectoredNounCount).toBe(baseline + 1)

View file

@ -176,21 +176,22 @@ describe('vector-leg open-build (two-engine gate, last red)', () => {
}) })
it('the inverse: only deferred (never-landed) user nouns — the ledger is never inflated by them, and search over them honestly returns []', async () => { it('the inverse: only deferred (never-landed) user nouns — the ledger is never inflated by them, and search over them honestly returns []', async () => {
// ARCHITECTURAL NOTE (found while building this pin): every brainy store // ARCHITECTURAL NOTE (updated by the zero-norm root cure): every brainy
// carries ONE permanent, always-vectored noun beyond user data — the VFS // store carries ONE permanent VFS root noun beyond user data
// root (`entities/nouns/.../00000000-0000-0000-0000-000000000000`, // (`entities/nouns/.../00000000-0000-0000-0000-000000000000`,
// src/vfs/VirtualFileSystem.ts). It is inserted with an explicit all-zero // src/vfs/VirtualFileSystem.ts), created (or, on a pre-fix store,
// (but non-empty, length-384) vector on EVERY store's first open — never // migrated) on every open — but it is deliberately UNVECTORED (vector
// deferred (a deliberate WASM-cold-compile-avoidance fix, see that // `[]`), never a real all-zero placeholder: a zero-norm vector is not a
// file's comment) — and VFS init unconditionally re-creates it if // vector and never crosses an engine boundary (see that file's
// missing, before the rebuild gate ever runs. A literal "0 vectored // doInitializeRoot() comment). It therefore contributes NOTHING to the
// nouns" store is therefore unreachable through the public API; a // vectored-noun ledger — a brand-new store's `vectors.all` floor is 0,
// brand-new store's `vectors.all` floor is 1, not 0. This pin verifies // not 1. This pin verifies the law the task names in the ACHIEVABLE
// the law the task names in the ACHIEVABLE form: nouns whose embed is // form: nouns whose embed is still deferred/unlanded contribute NOTHING
// still deferred/unlanded contribute NOTHING to the vectored-noun ledger // to the vectored-noun ledger either — the coverage-gap comparison sees
// — the coverage-gap comparison sees exactly the root (1), never // exactly the baseline (the root, contributing 0), never
// root+deferred — and semantic search over deferred-only user content // baseline+deferred — and semantic search over deferred-only user
// honestly returns `[]` (no error, no false "coverage restored" claim). // content honestly returns `[]` (no error, no false "coverage restored"
// claim).
const dir = mkTmp() const dir = mkTmp()
const build: any = new Brainy({ const build: any = new Brainy({
@ -202,6 +203,8 @@ describe('vector-leg open-build (two-engine gate, last red)', () => {
}) })
await build.init() await build.init()
const rootOnlyLedger = await build.storage.getCanonicalCounts() const rootOnlyLedger = await build.storage.getCanonicalCounts()
// THE NEW LAW: the root is unvectored — a brand-new store's floor is 0.
expect(rootOnlyLedger.vectors.all).toBe(0)
// Block the embedder permanently so every add below stays deferred and // Block the embedder permanently so every add below stays deferred and
// unlanded for the rest of this test (a fast deterministic embedder // unlanded for the rest of this test (a fast deterministic embedder
// could otherwise land it before we ever observe the "still 0 extra" // could otherwise land it before we ever observe the "still 0 extra"

View file

@ -0,0 +1,203 @@
/**
* @module tests/integration/vfs-root-zero-norm
* @description THE ZERO-NORM ROOT CURE a production incident traced 150+
* darkened rows in a downstream engine's index to the VFS root's persisted
* ALL-ZERO placeholder vector: lawful inside brainy (`cosineDistance`
* treats a zero-norm operand as MAXIMUM distance, src/utils/distance.ts)
* but a "false attractor" for an engine serving squared-euclidean distance,
* which cannot tell a real all-zero vector apart from a legitimate origin
* point. THE LAW: a zero-norm vector is not a vector it never crosses an
* engine boundary.
*
* Three legs pinned here:
* (a) the root persists NO zeros a brand-new store creates it with
* vector `[]` (the "unvectored" shape), absent from the HNSW index, and
* the canonical vectored-noun ledger does not count it.
* (b) a ONE-TIME migration heals an existing (pre-fix) store: an old-shape
* root (a REAL all-zero vector, genuinely indexed and ledgered the
* harness reproduces exactly what a pre-fix store looked like on disk)
* is rewritten to `[]` on the next `init()`, the ledger is decremented
* through the sanctioned path, and a second `init()` is a no-op.
* (c) THE BELT at the live provider-write seam: an entity added with an
* EXPLICIT all-zero vector (any dimension) still lands its canonical
* write, but the vector-index insert is refused loudly.
* (d) the migrated root never surfaces in `find()` results (it was already
* hidden behind `visibility: 'system'` this pin holds regardless).
*/
import { describe, it, expect, afterEach, vi } from 'vitest'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { Brainy } from '../../src/index.js'
import { NounType } from '../../src/types/graphTypes.js'
import { prodLog } from '../../src/utils/logger.js'
const ROOT_ID = '00000000-0000-0000-0000-000000000000'
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
const tmpDirs: string[] = []
function mkTmp(): string {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-vfs-root-zero-norm-'))
tmpDirs.push(d)
return d
}
afterEach(() => {
vi.restoreAllMocks()
for (const d of tmpDirs.splice(0)) fs.rmSync(d, { recursive: true, force: true })
})
function openBrain(dir: string): any {
return new Brainy({
requireSubtype: false,
storage: { type: 'filesystem', path: dir },
silent: true,
dimensions: 384
})
}
describe('VFS root zero-norm cure', () => {
it('(a) a brand-new store persists the root with vector [], absent from the HNSW index, and the canonical ledger counts it unvectored', async () => {
const dir = mkTmp()
const brain = openBrain(dir)
await brain.init()
const root = await brain.get(ROOT_ID, { includeVectors: true })
expect(root).not.toBeNull()
expect(root.vector).toEqual([])
const status = await brain.getIndexStatus()
expect(status.hnswIndex.size).toBe(0)
const ledger = await brain.storage.getCanonicalCounts()
expect(ledger.vectors.all).toBe(0)
await brain.close()
})
it('(b) an old-shape store (a real all-zero placeholder root) migrates to [] exactly once on init; the ledger is decremented through the sanctioned path; a second init is a no-op', async () => {
const dir = mkTmp()
// SESSION 1 — build the store, then hand-rewrite the root to the LEGACY
// shape: a REAL all-zero 384-dim vector, genuinely inserted into the
// vector index and genuinely counted by the vectored-noun ledger —
// reproducing exactly what a pre-fix store's root looked like on disk
// (the pre-fix add() always indexed + counted it). `index.addItem` is
// called directly (bypassing AddToVectorIndexOperation's own zero-norm
// belt, added by this same fix) precisely because the pre-fix code path
// had no such belt — this harness must match history, not the cure.
let brain = openBrain(dir)
await brain.init()
const oldVector = new Array(384).fill(0)
await brain.storage.saveNoun({ id: ROOT_ID, vector: oldVector, connections: new Map(), level: 0 })
await brain.index.addItem({ id: ROOT_ID, vector: oldVector })
await brain.storage.noteVectorLanded(ROOT_ID)
await brain.storage.persistCounts()
await brain.flush()
const ledgerBeforeMigration = await brain.storage.getCanonicalCounts()
expect(ledgerBeforeMigration.vectors.all).toBe(1)
await brain.close()
// SESSION 2 — reopen: VFS init must detect the legacy shape and migrate.
// Spy on the sanctioned migration method itself (not console output —
// `silent: true` monkey-patches `console.log` to a no-op INSIDE init(),
// which would silently swallow any pre-installed console spy too).
brain = openBrain(dir)
const migrateSpy = vi.spyOn(brain, 'unvectorNounForRootMigration')
await brain.init()
expect(migrateSpy).toHaveBeenCalledTimes(1)
expect(migrateSpy).toHaveBeenCalledWith(ROOT_ID)
await expect(migrateSpy.mock.results[0].value).resolves.toBe(true)
const migratedRoot = await brain.get(ROOT_ID, { includeVectors: true })
expect(migratedRoot.vector).toEqual([])
const ledgerAfterMigration = await brain.storage.getCanonicalCounts()
expect(ledgerAfterMigration.vectors.all).toBe(0)
const statusAfterMigration = await brain.getIndexStatus()
expect(statusAfterMigration.hnswIndex.size).toBe(0)
await brain.flush()
await brain.close()
// SESSION 3 — reopen again: the migration is a permanent no-op, not a
// one-time flag that silently re-drifts or re-fires. The zero-norm
// detection at the VFS init site never even calls the migration method
// again — the root's vector is already `[]`.
brain = openBrain(dir)
const migrateSpy2 = vi.spyOn(brain, 'unvectorNounForRootMigration')
await brain.init()
expect(migrateSpy2).not.toHaveBeenCalled()
const rootAfterSecondInit = await brain.get(ROOT_ID, { includeVectors: true })
expect(rootAfterSecondInit.vector).toEqual([])
const ledgerAfterSecondInit = await brain.storage.getCanonicalCounts()
expect(ledgerAfterSecondInit.vectors.all).toBe(0)
await brain.close()
})
it('(c) the live-write belt: an entity added with an explicit all-zero vector lands its canonical write, but the vector-index insert is refused loudly', async () => {
const dir = mkTmp()
const brain = openBrain(dir)
await brain.init()
const warnSpy = vi.spyOn(prodLog, 'warn')
const sizeBefore = (await brain.getIndexStatus()).hnswIndex.size
const zeroVector = new Array(384).fill(0)
const id = await brain.add({ data: 'poisoned entity', type: NounType.Document, vector: zeroVector })
// The canonical write succeeded — the entity is fully readable with its
// (real, all-zero) vector intact.
const entity = await brain.get(id, { includeVectors: true })
expect(entity).not.toBeNull()
expect(entity.vector).toEqual(zeroVector)
// The vector-index insert was skipped — the index size never moved.
const sizeAfter = (await brain.getIndexStatus()).hnswIndex.size
expect(sizeAfter).toBe(sizeBefore)
// The refusal was LOUD and named the entity.
const loudCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === 'string' && call[0].includes(id) && call[0].toLowerCase().includes('zero-norm')
)
expect(loudCall).toBeDefined()
await brain.close()
})
it('(d) find() over a store whose root has been migrated never returns the root (already hidden behind visibility: system — pinned anyway)', async () => {
const dir = mkTmp()
// Build an old-shape store (same harness as pin (b)) and let it migrate.
let brain = openBrain(dir)
await brain.init()
const oldVector = new Array(384).fill(0)
await brain.storage.saveNoun({ id: ROOT_ID, vector: oldVector, connections: new Map(), level: 0 })
await brain.index.addItem({ id: ROOT_ID, vector: oldVector })
await brain.storage.noteVectorLanded(ROOT_ID)
await brain.storage.persistCounts()
await brain.add({ data: 'a document about technology', type: NounType.Document })
await brain.flush()
await brain.close()
brain = openBrain(dir) // migrates on init()
await brain.init()
const results = await brain.find({ query: 'technology', limit: 10 })
expect(results.some((r: any) => r.id === ROOT_ID)).toBe(false)
// Even asking explicitly for system-tier entities must never surface the
// root as a semantic-search HIT (it carries no vector to match against).
const resultsIncludingSystem = await brain.find({ query: 'technology', limit: 10, includeSystem: true })
expect(resultsIncludingSystem.some((r: any) => r.id === ROOT_ID)).toBe(false)
await brain.close()
})
})