/** * @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())) }) })