brainy/tests/integration/count-invariant.test.ts
David Snelling 119087a75c fix: spine hardening pass 1 (part) — count symmetry, honest partial-load, flush durability, read-fault propagation
Write/index-spine hardening, first batch of Pass 1. Each fix restores an
invariant the surrounding code already intended; every one has a
fail-before/pass-after test.

- Pattern C, finding 5 (baseStorage): delete now decrements the user-facing
  scalar total symmetrically — deleteNounMetadata was decrementing only the
  per-type bucket, deleteVerbMetadata neither the bucket nor the scalar, so
  getNounCount()/getVerbCount() inflated permanently (the stale scalar wins
  pagination via Math.max and is persisted). Invariant now holds:
  scalar total === Σ per-type across add/update/delete and reopen.

- Pattern A, finding 3 (graph/lsm/LSMTree): a partial SSTable-load failure no
  longer publishes the manifest's full relationship count as healthy. Any
  per-SSTable load failure throws after the batch, which resets to honest-empty
  and lets the existing size()===0 self-heal rebuild run — size()/isHealthy()
  can no longer lie about a partial load.

- Pattern B, finding 6 (hnsw/hnswIndex): deferred flush() no longer clears
  dirty nodes whose connections failed to persist — failed nodes stay in the
  retry set, and flush() throws HnswFlushError instead of returning a lying
  node count. The immediate-mode first-noun saveHNSWSystem is un-swallowed, so
  addItem() rejects rather than returning an id for a rootless index.

- Pattern B, finding 11 (part — storage reads): new shared isAbsentError()
  helper (utils/errorClassification, ENOENT-only absence) applied to
  loadBinaryBlob and readObjectFromPath — a real IO fault (EIO/EACCES/EMFILE)
  now propagates loudly instead of masquerading as "absent", which had driven
  needless rebuilds / empty reads (loadBinaryBlob feeds the native provider).

Regression: 78 green across the 3 new suites + db-mvcc, generationStore,
temporal-vfs, rollback-trapdoor, restore-nondestructive. Full gate runs before
the Pass-1 release (David-gated). Remaining Pass 1: finding 11 getNoun/getVerb
legs, finding 4 (ColumnStore), finding 8 (pending-flush), finding 10 (degraded),
finding 7 (clear). Pattern A guards (1,2,9) as a follow-up release.
2026-07-13 08:50:07 -07:00

109 lines
4.6 KiB
TypeScript

/**
* @module tests/integration/count-invariant
* @description Pattern-C acceptance: the user-facing scalar total and the
* per-type array are ONE consistent projection — `getNounCount() === Σ
* nounCountsByType` (and the verb mirror) after any interleaving of add /
* update-visibility-flip / delete, AND across a reopen.
*
* The bug (finding 5): delete decremented the per-type array but never the
* scalar for nouns, and neither for verbs — so `getNounCount()`/`getVerbCount()`
* inflated permanently (the stale scalar wins pagination via
* `Math.max(total, collected.length)` and is persisted). The fix restores the
* symmetric decrement the code always intended (its own comments say "symmetric
* with the increments"). This test would report an inflated count before the
* fix and the exact count after.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { Brainy } from '../../src/brainy.js'
import { NounType, VerbType } from '../../src/types/graphTypes.js'
const sum = (a: Uint32Array): number => a.reduce((s, c) => s + c, 0)
describe('count invariant — scalar total === Σ per-type, across delete + reopen (finding 5)', () => {
let dir: string
let brain: any
const open = async (d: string) => {
const b = new Brainy({
requireSubtype: false,
storage: { type: 'filesystem', path: d },
dimensions: 384,
silent: true
})
await b.init()
return b
}
beforeEach(async () => {
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-count-inv-'))
brain = await open(dir)
})
afterEach(async () => {
try { await brain.close() } catch { /* already closed */ }
fs.rmSync(dir, { recursive: true, force: true })
})
it('noun delete decrements the scalar total, not just the per-type bucket', async () => {
const things: string[] = []
for (let i = 0; i < 5; i++) things.push(await brain.add({ data: `thing ${i}`, type: NounType.Thing }))
for (let i = 0; i < 3; i++) await brain.add({ data: `concept ${i}`, type: NounType.Concept })
const storage = brain['storage']
expect(await storage.getNounCount()).toBe(8)
expect(sum(storage.getNounCountsByType())).toBe(8)
// Delete 2 Things — the scalar must drop with the bucket (pre-fix it stayed 8).
await brain.remove(things[0])
await brain.remove(things[1])
expect(await storage.getNounCount()).toBe(6)
expect(sum(storage.getNounCountsByType())).toBe(6)
// The invariant: scalar === Σ per-type.
expect(await storage.getNounCount()).toBe(sum(storage.getNounCountsByType()))
})
it('verb delete decrements BOTH the scalar total AND the per-type bucket', async () => {
const ids: string[] = []
for (let i = 0; i < 4; i++) ids.push(await brain.add({ data: `node ${i}`, type: NounType.Thing }))
const edges: string[] = []
edges.push(await brain.relate({ from: ids[0], to: ids[1], type: VerbType.RelatedTo }))
edges.push(await brain.relate({ from: ids[1], to: ids[2], type: VerbType.RelatedTo }))
edges.push(await brain.relate({ from: ids[2], to: ids[3], type: VerbType.Contains }))
edges.push(await brain.relate({ from: ids[0], to: ids[3], type: VerbType.Contains }))
const storage = brain['storage']
expect(await storage.getVerbCount()).toBe(4)
expect(sum(storage.getVerbCountsByType())).toBe(4)
// Unrelate one edge — pre-fix, verb delete touched NEITHER counter (both stayed 4).
await brain.unrelate(edges[0])
expect(await storage.getVerbCount()).toBe(3)
expect(sum(storage.getVerbCountsByType())).toBe(3)
expect(await storage.getVerbCount()).toBe(sum(storage.getVerbCountsByType()))
})
it('the corrected counts persist and survive a reopen', async () => {
const things: string[] = []
for (let i = 0; i < 6; i++) things.push(await brain.add({ data: `t${i}`, type: NounType.Thing }))
const a = await brain.relate({ from: things[0], to: things[1], type: VerbType.RelatedTo })
await brain.relate({ from: things[1], to: things[2], type: VerbType.RelatedTo })
await brain.remove(things[5])
await brain.unrelate(a)
await brain.flush()
await brain.close()
// Reopen from disk — counts.json must carry the corrected totals.
brain = await open(dir)
const storage = brain['storage']
expect(await storage.getNounCount()).toBe(5)
expect(await storage.getVerbCount()).toBe(1)
expect(await storage.getNounCount()).toBe(sum(storage.getNounCountsByType()))
expect(await storage.getVerbCount()).toBe(sum(storage.getVerbCountsByType()))
})
})