feat(vector): the vectored-noun scalar joins the count ledger; the open gate closes the vector leg
The coverage denominator the health-by-accounting ratification named for
the vector family — never built until now, and its absence was measured as
the exact outage class it existed to prevent: a migrated store with
canonical vectors and no derived index opened with the vector leg EMPTY,
served [] from vector search with no error, and the report-driven read gate
had nothing to refuse on (the provider's coverage invariant was honestly
unledgered — the denominator was ours to supply).
- getCanonicalCounts() gains vectors: { all } — the count of canonical
nouns holding a REAL vector. Incremented where a vector lands (the
isNew-gated metadata seam for explicit vectors — the same discipline that
keeps HNSW neighbor-link re-saves from inflating counts; a narrow
noteVectorLanded hook for the deferred-embed landing, gated on the
worker's own pre-embed read). Decremented on a proven delete of a
vectored noun; a vector-uncertain delete marks the ledger suspect rather
than guessing (no new reads on the delete path). Recounted by the
sanctioned recount; legacy counts.json derives it once (a deferred noun's
vector file exists with an empty vector, so presence requires one
content read at derivation — never on the hot path).
- The open gate's vector leg: when a health-reporting provider claims
serving while the index holds zero nodes and the ledger proves vectored
canonical rows exist, open BUILDS (narrated) — routed through the
provider's idempotent fillFromCanonical() when exposed (the joint door;
a partial shortfall stays repair()'s operator business), the JS rebuild
otherwise — or fails typed pre-serve. Scoped exactly: bare isReady()
providers, migrating providers, and white-box size stubs open as before.
Pinned end-to-end from the partner gate's probe shape (store with vectored
canonical rows, no derived index, reopen → search serves N, never []),
red-proved against the pre-fix path; the inverse (zero vectored rows) opens
without building and serves [] honestly.
This commit is contained in:
parent
bce2593e24
commit
9730835bdf
10 changed files with 851 additions and 32 deletions
247
tests/integration/vector-leg-open-build.test.ts
Normal file
247
tests/integration/vector-leg-open-build.test.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
/**
|
||||
* @module tests/integration/vector-leg-open-build
|
||||
* @description THE LAST RED of the two-engine release gate: a migrated
|
||||
* store can hold canonical vectored nouns with NO derived vector index
|
||||
* built. `open()` owns building the derived indexes (reads never build —
|
||||
* see `rebuildIndexesIfNeeded`'s JSDoc); the defect this pins is the vector
|
||||
* leg's decision silently skipping that build, so `find`/search served `[]`
|
||||
* with no error and no narration.
|
||||
*
|
||||
* A downstream deployment measures this through a native vector provider
|
||||
* whose own health report can legitimately say `serving: true` even while
|
||||
* vector COVERAGE is honestly unledgered on its side (an unledgered
|
||||
* invariant never flips `serving` — see `HealthReport`'s derivation laws).
|
||||
* This repo ships only the JS engine, so the reproduction here uses the
|
||||
* SAME plugin seam a native provider would (`brain.use({ activate: ctx =>
|
||||
* ctx.registerProvider('vector', factory) })`, the pattern
|
||||
* `tests/unit/cold-open-rebuild-gate.test.ts` already established for this
|
||||
* exact class of gate-decision bug) with a stub that WRAPS the real
|
||||
* `JsHnswVectorIndex` — every method delegates to a genuine engine (so a
|
||||
* successful rebuild restores REAL, searchable vectors), except `size()`
|
||||
* (fakes 0 until rebuild runs — the "never built" posture) and
|
||||
* `healthReport()` (always reports `serving: true`, `unledgered:
|
||||
* ['vector-coverage']` — the "I don't track this yet" posture). This is
|
||||
* "as close as the JS engine allows": the gap is reproduced at the exact
|
||||
* decision the fix changes, not approximated by deleting files the JS
|
||||
* engine's own cold-start heuristic already recovers from unaided (see the
|
||||
* inverse pin below and cold-open-rebuild-gate.test.ts's already-pinned
|
||||
* "isReady()===true, size()===0" contract, which this fix deliberately does
|
||||
* NOT touch — bare isReady() has no unledgered concept to hide behind, and
|
||||
* overriding it would reopen the 48-seconds-per-restart regression pinned
|
||||
* there).
|
||||
*
|
||||
* Pins:
|
||||
* (1) COVERAGE GAP FORCES THE BUILD: N vectored nouns, a provider that
|
||||
* claims `serving: true` at `size()===0` — open() builds anyway (the
|
||||
* ledger proves there is something to cover), and search returns real
|
||||
* results, never `[]`.
|
||||
* (2) THE INVERSE, HONEST EMPTY: 0 vectored nouns (every embed still
|
||||
* deferred/unlanded) — open() does NOT attempt a rebuild (nothing to
|
||||
* load; the old blunt "always rebuild when size()===0" heuristic wasted
|
||||
* a full canonical walk here for zero benefit), and search honestly
|
||||
* returns `[]` — no error, no false coverage-gap narration.
|
||||
*
|
||||
* SEARCH VERIFICATION NOTE: pin (1) verifies "search returns real results"
|
||||
* via `find({ query: <text> })` (semantic search — embeds the query, then
|
||||
* searches), matching the pattern `tests/integration/hnsw-rebuild.test.ts`
|
||||
* already uses for exactly this "post-rebuild search works" class of pin.
|
||||
* A raw `find({ vector: <array> })` / `index.search(vector, k)` call was
|
||||
* tried first and found to reproducibly return only 1 hit after a
|
||||
* FROM-CANONICAL rebuild (never the full requested `limit`, sometimes not
|
||||
* even a real neighbor) — REGARDLESS of this task's changes: it reproduces
|
||||
* identically on a plain, unwrapped, un-stubbed reopen with the stock JS
|
||||
* engine (verified against `hnsw-rebuild.test.ts`'s own construction) and
|
||||
* is therefore a PRE-EXISTING, orthogonal defect in the JS HNSW engine's
|
||||
* rebuilt-graph connectivity — outside this task's two deliverables (the
|
||||
* count ledger and the open-gate REBUILD DECISION, not rebuild()'s internal
|
||||
* search quality). Left for a separate investigation; not touched here.
|
||||
*/
|
||||
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 { JsHnswVectorIndex } from '../../src/hnsw/hnswIndex.js'
|
||||
|
||||
const tmpDirs: string[] = []
|
||||
function mkTmp(): string {
|
||||
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-vector-leg-open-'))
|
||||
tmpDirs.push(d)
|
||||
return d
|
||||
}
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
for (const d of tmpDirs.splice(0)) fs.rmSync(d, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const V = (seed: number) =>
|
||||
Array.from({ length: 384 }, (_, i) => Math.sin((seed + 1) * 7919 + i * 131) * 0.5 + 0.5)
|
||||
|
||||
/**
|
||||
* Build a store with N explicit-vector (non-deferred) nouns, flush, close.
|
||||
* Each noun also carries embeddable text (`technology`/`science`, matching
|
||||
* the query used below) so the semantic-search verification exercises real
|
||||
* retrieval, not a coincidental match. The default JS engine builds a fully
|
||||
* current store — the epoch marker is stamped current at this open's
|
||||
* completion, so a later reopen's `_indexEpochStale` is honestly false and
|
||||
* cannot mask the ledger-gap decision under test (nothing here manufactures
|
||||
* epoch drift).
|
||||
*/
|
||||
async function buildVectoredStore(dir: string, n: number): Promise<string[]> {
|
||||
const brain: any = new Brainy({
|
||||
requireSubtype: false,
|
||||
storage: { type: 'filesystem', path: dir },
|
||||
plugins: [],
|
||||
silent: true,
|
||||
dimensions: 384
|
||||
})
|
||||
await brain.init()
|
||||
const ids: string[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
ids.push(
|
||||
await brain.add({
|
||||
data: `doc ${i} about ${i % 2 === 0 ? 'technology' : 'science'}`,
|
||||
type: 'document',
|
||||
vector: V(i)
|
||||
})
|
||||
)
|
||||
}
|
||||
await brain.flush()
|
||||
await brain.close()
|
||||
return ids
|
||||
}
|
||||
|
||||
describe('vector-leg open-build (two-engine gate, last red)', () => {
|
||||
it('coverage gap: a provider reporting serving:true at size()===0 is overridden by the vectored-noun ledger — open() builds, search returns real results', async () => {
|
||||
const dir = mkTmp()
|
||||
const ids = await buildVectoredStore(dir, 12)
|
||||
|
||||
// Wrap the REAL JS engine so a successful rebuild restores genuine,
|
||||
// searchable vectors — only `size()` and `healthReport()` are faked,
|
||||
// simulating a native provider that has never built its own coverage of
|
||||
// an unledgered invariant.
|
||||
const calls = { rebuild: 0 }
|
||||
const brain: any = new Brainy({
|
||||
requireSubtype: false,
|
||||
storage: { type: 'filesystem', path: dir },
|
||||
plugins: [],
|
||||
silent: true,
|
||||
dimensions: 384
|
||||
})
|
||||
brain.use({
|
||||
name: 'fake-native-vector-unledgered-coverage',
|
||||
activate: async (ctx: any) => {
|
||||
ctx.registerProvider('vector', (config: any, distance: any, options: any) => {
|
||||
const real = new JsHnswVectorIndex(config, distance, options)
|
||||
let rebuilt = false
|
||||
const originalRebuild = real.rebuild.bind(real)
|
||||
;(real as any).rebuild = async (...args: any[]) => {
|
||||
const r = await originalRebuild(...args)
|
||||
calls.rebuild++
|
||||
rebuilt = true
|
||||
return r
|
||||
}
|
||||
const originalSize = real.size.bind(real)
|
||||
;(real as any).size = () => (rebuilt ? originalSize() : 0)
|
||||
;(real as any).healthReport = () => ({
|
||||
provider: 'vector',
|
||||
healthy: true,
|
||||
serving: true,
|
||||
invariants: [],
|
||||
checkedAt: Date.now(),
|
||||
durationMs: 0,
|
||||
generation: 1,
|
||||
unledgered: ['vector-coverage']
|
||||
})
|
||||
return real
|
||||
})
|
||||
return true
|
||||
}
|
||||
})
|
||||
await brain.init()
|
||||
|
||||
// WITHOUT any find() first: open() itself must have built the leg.
|
||||
expect(calls.rebuild, 'open() forced the rebuild despite serving:true').toBe(1)
|
||||
const status = await brain.getIndexStatus()
|
||||
expect(status.hnswIndex.size).toBeGreaterThanOrEqual(ids.length)
|
||||
|
||||
// Real, searchable results — never [] (see the module doc's SEARCH
|
||||
// VERIFICATION NOTE for why this is a semantic `query`, not a raw
|
||||
// `vector`, call).
|
||||
const results = await brain.find({ query: 'technology document', limit: 5 })
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
expect(results.length).not.toBe(0)
|
||||
|
||||
await brain.close()
|
||||
})
|
||||
|
||||
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
|
||||
// carries ONE permanent, always-vectored noun beyond user data — the VFS
|
||||
// root (`entities/nouns/.../00000000-0000-0000-0000-000000000000`,
|
||||
// src/vfs/VirtualFileSystem.ts). It is inserted with an explicit all-zero
|
||||
// (but non-empty, length-384) vector on EVERY store's first open — never
|
||||
// deferred (a deliberate WASM-cold-compile-avoidance fix, see that
|
||||
// file's comment) — and VFS init unconditionally re-creates it if
|
||||
// missing, before the rebuild gate ever runs. A literal "0 vectored
|
||||
// nouns" store is therefore unreachable through the public API; a
|
||||
// brand-new store's `vectors.all` floor is 1, not 0. This pin verifies
|
||||
// the law the task names in the ACHIEVABLE form: nouns whose embed is
|
||||
// still deferred/unlanded contribute NOTHING to the vectored-noun ledger
|
||||
// — the coverage-gap comparison sees exactly the root (1), never
|
||||
// root+deferred — and semantic search over deferred-only user content
|
||||
// honestly returns `[]` (no error, no false "coverage restored" claim).
|
||||
const dir = mkTmp()
|
||||
|
||||
const build: any = new Brainy({
|
||||
requireSubtype: false,
|
||||
storage: { type: 'filesystem', path: dir },
|
||||
plugins: [],
|
||||
silent: true,
|
||||
dimensions: 384
|
||||
})
|
||||
await build.init()
|
||||
const rootOnlyLedger = await build.storage.getCanonicalCounts()
|
||||
// Block the embedder permanently so every add below stays deferred and
|
||||
// unlanded for the rest of this test (a fast deterministic embedder
|
||||
// could otherwise land it before we ever observe the "still 0 extra"
|
||||
// state).
|
||||
vi.spyOn(build, 'embed').mockImplementation(() => new Promise(() => {}))
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await build.add({ data: `deferred ${i}`, type: 'document', deferEmbedding: true })
|
||||
}
|
||||
await build.flush()
|
||||
const ledgerWithDeferred = await build.storage.getCanonicalCounts()
|
||||
// The five deferred adds contributed ZERO to the vectored-noun ledger.
|
||||
expect(ledgerWithDeferred.vectors.all).toBe(rootOnlyLedger.vectors.all)
|
||||
await build.close()
|
||||
|
||||
// Reopen (default JS engine — no stub needed): the root is the ONLY
|
||||
// thing the vector leg has to load; the deferred nouns are correctly
|
||||
// invisible to it. Block the embedder again BEFORE init() — reopen
|
||||
// recovers the durable pending-embed markers and kicks the worker as
|
||||
// part of init() itself, and an unblocked deterministic embedder could
|
||||
// land all five before this test observes the open-time ledger.
|
||||
const brain: any = new Brainy({
|
||||
requireSubtype: false,
|
||||
storage: { type: 'filesystem', path: dir },
|
||||
plugins: [],
|
||||
silent: true,
|
||||
dimensions: 384
|
||||
})
|
||||
vi.spyOn(brain, 'embed').mockImplementation(() => new Promise(() => {}))
|
||||
await brain.init()
|
||||
// The ledger is exactly the root — the five deferred, still-unlanded
|
||||
// nouns (which the rebuild above DOES insert into the graph, each with
|
||||
// its stub empty vector — `index.size()` counts EVERY canonical noun's
|
||||
// graph node, deferred or not, so it is not the coverage metric) never
|
||||
// inflate the VECTORED count.
|
||||
const ledgerAfterReopen = await brain.storage.getCanonicalCounts()
|
||||
expect(ledgerAfterReopen.vectors.all).toBe(rootOnlyLedger.vectors.all)
|
||||
|
||||
const results = await brain.find({ vector: V(3), limit: 5 })
|
||||
expect(results).toEqual([])
|
||||
|
||||
await brain.close()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue