110 lines
4.6 KiB
TypeScript
110 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()))
|
||
|
|
})
|
||
|
|
})
|