fix(vectors): a zero-norm vector is not a vector, canonical side included, plus the sanctioned unvector door
Some checks failed
CI / Node 22 (push) Successful in 12m22s
CI / Node 24 (push) Successful in 12m15s
CI / Integration + conformance (Node 22) (push) Failing after 14m49s
CI / Bun (latest) (push) Successful in 12m28s

The engine-pair seam law: a zero-norm vector never crosses an engine
boundary. The index belt already refused to insert one, but the canonical
write and the vectored-noun ledger still counted it, so a near-empty store
whose only vectored row was zero-norm read "1 canonical vectored vs 0
indexed" and threw a not-ready error at open, and a store's own legacy
zero-norm VFS root could trip the same gate before its VFS-init-time cure
ever ran.

- add()/update() (single and transact()) now normalize an explicit
  real all-zero vector to the unvectored [] shape before the dimension
  pin, the ledger flag, and the index ops ever see it (loud, one warn per
  write, canonical write still succeeds).
- The legacy counts.json derivation walk (scanVectoredNounCount) excludes
  a persisted zero-norm row, matching the live ledger's definition.
- A legacy zero-norm VFS root now migrates at open, before the vector-leg
  gate evaluates, via one O(1) fixed-path read (torn-tolerant — skips
  rather than aborting init on a torn root, letting the recovery walk
  heal it) — independent of whether a VirtualFileSystem is ever
  constructed this session.
- update({ id, vector: [] }) (and the same op inside transact()) is now
  the sanctioned, idempotent unvector door: index removal, exactly-once
  ledger decrement, no re-embed, and it clears a pending deferred-embed
  marker rather than leaving it to re-vectorize the row later. The
  combination with deferEmbedding is a typed refusal.
- JsHnswVectorIndex.rebuild() now skips a zero-norm/empty persisted
  vector when repopulating from canonical (the same belt the live
  add/replace paths already had), and health()'s index-parity check now
  compares HNSW size against the vectored-noun ledger rather than the
  raw metadata-entry count, since a store's VFS root is permanently
  unvectored by design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
David Snelling 2026-08-27 13:53:09 -07:00
parent fd6b4ce4ff
commit 0de7665930
6 changed files with 810 additions and 53 deletions

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'
@ -1768,6 +1768,33 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
// Process all nouns at once
for (const nounData of result.items) {
try {
// THE ZERO-NORM LAW / THE INDEX BELT — bulk-rebuild leg: a row
// holding no REAL vector (the "unvectored" `[]` shape — a
// deferred embed's stub, or a row the sanctioned unvector door
// rewrote) must never enter the index, mirroring the guard
// AddToVectorIndexOperation/ReplaceInVectorIndexOperation already
// enforce on the live transactional write paths. This matters
// HERE specifically: a row can be unvectored (canonical vector
// rewritten to `[]`) while its PERSISTED HNSW graph metadata
// (`getVectorIndexData` — level/connections) is still present
// from before the unvector, so `hnswData`'s mere presence below
// is not proof the row belongs in the index — only the
// canonical vector itself is authoritative. Checked against
// `nounData.vector` (the FULL loaded vector), never the
// to-be-truncated `noun.vector` below, so this holds regardless
// of the eager/lazy preload strategy chosen further down.
if (!Array.isArray(nounData.vector) || nounData.vector.length === 0) {
continue
}
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
}
// Load HNSW graph data for this entity
const hnswData = await this.storage.getVectorIndexData(nounData.id)