brainy/tests/integration/entity-tree-stamp.test.ts
David Snelling 352e356fd5 feat: provider access to the fact log + shared stamp verifier via internals
Three additive surfaces for native index providers, which hold only
`storage` and must never construct their own fact-log reader (the log's
open path is writer-side — it reconciles by truncating/rewriting):

- Fact-scan capability on the storage adapter (the getBinaryBlobPath
  pattern): the host brain wires a closure over its LIVE fact log at
  init; providers call storage.scanFacts()/factLogHeadGeneration()/
  factSegmentPaths() and fall back to the enumeration walk on null.
  Restore/reopen swaps the underlying log transparently.
- The family-stamp trio (readFamilyStamp/writeFamilyStamp/
  verifyFamilyStamp + types) exported from @soulcraft/brainy/internals,
  so 'one verifier' is literally one function shared with the native
  side, never two synchronized copies.
- Rollup invariants widened to number|string: content fingerprints
  (e.g. a per-tree SHA-256) are valid invariant values; strict equality
  either way, and a type mismatch reads as incoherence, never a pass.
2026-07-15 12:36:27 -07:00

163 lines
6.4 KiB
TypeScript

/**
* @module tests/integration/entity-tree-stamp
* @description The entity tree's FAMILY STAMP: written at flush/close with
* `sourceGeneration` (the committed generation the canonical tree reflects)
* plus rollup invariants (entity/relationship counts); verified at open by
* comparison — coherent on a clean close, LOUD on genuine incoherence
* (tampered counters), healed by repairIndex()'s recount + re-stamp. One
* verifier reads both member modes.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import {
Brainy,
readFamilyStamp,
verifyFamilyStamp,
ENTITY_TREE_STAMP_PATH,
type FamilyStamp
} from '../../src/index.js'
import { prodLog } from '../../src/utils/logger.js'
describe('entity-tree family stamp', () => {
let dir: string
let brain: any
const open = async () => {
const b: any = new Brainy({
requireSubtype: false,
storage: { type: 'filesystem', path: dir },
silent: true,
dimensions: 384
})
await b.init()
return b
}
beforeEach(async () => {
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-stamp-'))
brain = await open()
})
afterEach(async () => {
vi.restoreAllMocks()
await brain.close?.().catch(() => {})
fs.rmSync(dir, { recursive: true, force: true })
})
it('flush() writes the stamp: rollup mode, counts + sourceGeneration match live state', async () => {
for (let i = 0; i < 3; i++) await brain.add({ data: `s${i}`, type: 'document', metadata: { i } })
await brain.flush()
const stamp = (await readFamilyStamp(brain.storage, ENTITY_TREE_STAMP_PATH)) as FamilyStamp
expect(stamp).not.toBeNull()
expect(stamp.family).toBe('entity-tree')
expect(stamp.members.mode).toBe('rollup')
const invariants = (stamp.members as any).invariants
expect(invariants.nounCount).toBe(await brain.storage.getNounCount())
expect(invariants.verbCount).toBe(await brain.storage.getVerbCount())
expect(stamp.sourceGeneration).toBe(brain.generation())
expect(stamp.generation).toBeGreaterThanOrEqual(1)
})
it('a cleanly-closed store reopens COHERENT (no incoherence warning)', async () => {
await brain.add({ data: 'clean', type: 'document', metadata: {} })
await brain.close()
const warn = vi.spyOn(prodLog, 'warn')
brain = await open()
const stampWarnings = warn.mock.calls.filter((c) => String(c[0]).includes('entity-tree stamp'))
expect(stampWarnings).toEqual([])
})
it('tampered counters surface as INCOHERENT at open; repairIndex() heals + re-stamps', async () => {
for (let i = 0; i < 3; i++) await brain.add({ data: `t${i}`, type: 'document', metadata: { i } })
await brain.close()
// Simulate counter drift AFTER the stamp was written: inflate the
// persisted scalar the way the historical decrement-skip did.
brain = await open()
;(brain.storage as any).totalNounCount += 52
await (brain.storage as any).persistCounts()
await brain.close()
// The close boundary re-stamps with the inflated counter — so tamper the
// STAMP instead for a deterministic mismatch: stamped counts differ from
// the (inflated) live ones at the NEXT open only if the stamp is older.
// Rewrite the stamp with the honest counts + current sourceGeneration.
const raw = JSON.parse(
require('node:zlib')
.gunzipSync(fs.readFileSync(path.join(dir, `${ENTITY_TREE_STAMP_PATH}.gz`)))
.toString('utf-8')
) as FamilyStamp
const honest = { ...raw }
;(honest.members as any).invariants.nounCount -= 52
fs.writeFileSync(
path.join(dir, `${ENTITY_TREE_STAMP_PATH}.gz`),
require('node:zlib').gzipSync(JSON.stringify(honest))
)
const warn = vi.spyOn(prodLog, 'warn')
brain = await open()
const incoherent = warn.mock.calls.filter((c) => String(c[0]).includes('INCOHERENT'))
expect(incoherent.length).toBeGreaterThanOrEqual(1)
expect(String(incoherent[0][0])).toMatch(/nounCount/)
// The heal: recount from canonical + re-stamp → next open is quiet.
await brain.repairIndex()
await brain.close()
const warn2 = vi.spyOn(prodLog, 'warn')
brain = await open()
const stillIncoherent = warn2.mock.calls.filter((c) => String(c[0]).includes('INCOHERENT'))
expect(stillIncoherent).toEqual([])
})
it('the one verifier handles both member modes', () => {
const rollup: FamilyStamp = {
family: 'x',
generation: 1,
committedAt: new Date().toISOString(),
sourceGeneration: 5,
members: { mode: 'rollup', invariants: { nounCount: 10 } }
}
expect(verifyFamilyStamp(rollup, 5, { nounCount: 10 })).toEqual({ state: 'coherent' })
expect(verifyFamilyStamp(rollup, 5, { nounCount: 11 }).state).toBe('incoherent')
expect(verifyFamilyStamp(rollup, 9, { nounCount: 10 })).toEqual({
state: 'behind',
stampSource: 5,
head: 9
})
expect(verifyFamilyStamp(rollup, 3, { nounCount: 10 }).state).toBe('incoherent') // ahead of head
expect(verifyFamilyStamp(null, 5, {})).toEqual({ state: 'absent' })
const enumerated: FamilyStamp = {
family: 'y',
generation: 1,
committedAt: new Date().toISOString(),
sourceGeneration: 2,
members: { mode: 'enumerated', files: [{ path: 'a.bin', bytes: 128 }] }
}
expect(verifyFamilyStamp(enumerated, 2, { 'a.bin': 128 })).toEqual({ state: 'coherent' })
expect(verifyFamilyStamp(enumerated, 2, { 'a.bin': 64 }).state).toBe('incoherent')
expect(verifyFamilyStamp(enumerated, 2, {}).state).toBe('incoherent') // missing member
// Rollup invariants may be STRING fingerprints (e.g. a per-tree SHA-256):
// strict equality either way; a type mismatch reads as incoherence.
const fingerprinted: FamilyStamp = {
family: 'z',
generation: 1,
committedAt: new Date().toISOString(),
sourceGeneration: 3,
members: { mode: 'rollup', invariants: { treeDigest: 'abc123', rows: 42 } }
}
expect(verifyFamilyStamp(fingerprinted, 3, { treeDigest: 'abc123', rows: 42 })).toEqual({
state: 'coherent'
})
expect(verifyFamilyStamp(fingerprinted, 3, { treeDigest: 'deadbeef', rows: 42 }).state).toBe(
'incoherent'
)
expect(verifyFamilyStamp(fingerprinted, 3, { treeDigest: 'abc123', rows: '42' }).state).toBe(
'incoherent' // type mismatch never passes
)
})
})