/** * @module tests/lifecycle/biographyHarness * @description The referee for the LIFECYCLE LANE (see `biography.test.ts`): * a plain in-memory SHADOW MODEL of a brain's contents, updated by every act * the biography performs (add/update/remove/relate/updateRelation/vfs writes), * plus `verifyChapter()`, which asserts the live brain agrees with the model * after every chapter. No engine code runs inside the model — it is an * independent ledger, not a mirror of the implementation under test. * * COUNT SEMANTICS this harness encodes (verified against the live engine, * not assumed — see the module-level comments below for how each was * confirmed): * * - `getNounCount()` / `getVerbCount()` count PUBLIC-tier alive records only * (visibility absent or `'public'`) — `'internal'` and `'system'` are both * excluded. `storage.getCanonicalCounts()` mirrors that same PUBLIC-only * scalar as `counted`, and additionally reports `all` — every tier, * unfiltered — as the coverage-ledger denominator (see * tests/integration/canonical-count-ledger.test.ts). * - `brain.vfs.writeFile()` for a brand-new file at a path directly under the * VFS root creates exactly ONE new File noun plus ONE new `Contains` verb * (root -> file), and BOTH are ordinary PUBLIC records (no visibility * field is set) — so they count toward `getNounCount()`/`getVerbCount()` * as well as the canonical `all` scalars. Only the VFS ROOT entity itself * is `'system'`-tier (created once, at `init()`, before any biography * chapter runs) — that lone record is the only hidden-tier footprint the * model does not construct explicitly, so it is captured empirically via * `snapshotVfsBaseline()` immediately after `init()` rather than hardcoded. * - `related()` filters edges by the RELATION's own visibility tier, not by * the visibility of the entities the edge connects — flipping an entity to * `'internal'` does not hide its edges from `related()`. This lane never * sets relation visibility, so every relation the model tracks is exactly * as reachable as its presence in `model.relations` implies. * - `remove()` cascades: every relation touching the removed entity (as * `from` or `to`) is hard-deleted along with it. The model mirrors this by * deleting the relation entirely from `model.relations` (no relation * "alive" flag — presence in the map IS aliveness). */ import { expect } from 'vitest' import type { Brainy } from '../../src/brainy.js' import type { NounType, VerbType } from '../../src/types/graphTypes.js' import type { EntityVisibility, StorageAdapter } from '../../src/coreTypes.js' /** * One entity's complete lifecycle-relevant state, as the biography's acts * leave it. `alive: false` means the model believes the id has been removed * — the entry is KEPT (never deleted from the map) so `verifyChapter` can * assert the negative half of the contract: a dead id must read as `null`. */ export interface ShadowEntity { type: NounType subtype?: string metadata: Record visibility?: EntityVisibility alive: boolean } /** * One relation's complete lifecycle-relevant state. There is no `alive` * flag here — presence in {@link ShadowModel.relations} IS aliveness, * mirroring the engine's hard delete of the canonical verb record on * cascade (see the module header). */ export interface ShadowRelation { from: string to: string type: VerbType subtype?: string metadata: Record } /** * The independent truth ledger the biography updates on every act it * performs. `verifyChapter` checks the live brain against this — never the * other way around. */ export interface ShadowModel { entities: Map relations: Map /** * `getCanonicalCounts()` nouns.all / verbs.all captured right after * `init()`, before chapter 1 — the VFS root's own system-tier footprint. * Set once via {@link snapshotVfsBaseline}; never hardcoded. */ vfsBaselineNouns: number vfsBaselineVerbs: number /** * Public nouns/verbs created by `vfs.writeFile()` for a brand-new file at * a flat top-level path: exactly one File noun + one Contains verb per * call (see the module header). Bumped by {@link recordVfsFileWrite}. */ vfsFileNouns: number vfsContainsVerbs: number } /** A fresh, empty shadow model — call once before chapter 1. */ export function createModel(): ShadowModel { return { entities: new Map(), relations: new Map(), vfsBaselineNouns: 0, vfsBaselineVerbs: 0, vfsFileNouns: 0, vfsContainsVerbs: 0 } } /** Narrow, documented private-storage access (the same style already used by * `tests/helpers/durabilityKillMatrix.ts`'s `storeOf()`), needed because * `getCanonicalCounts()` lives on the storage adapter, not on `Brainy`. */ function storageOf(brain: Brainy): StorageAdapter { return (brain as unknown as { storage: StorageAdapter }).storage } /** Public wrapper around the private-storage `getCanonicalCounts()` read, so * callers never need their own private-access cast — used internally by * {@link snapshotVfsBaseline} and {@link verifyChapter}, and by * `biography.test.ts` for its final standalone exactness check. */ export async function getCanonicalCountsFor(brain: Brainy): ReturnType> { const storage = storageOf(brain) if (!storage.getCanonicalCounts) { throw new Error( 'lifecycle lane: the storage adapter under test has no getCanonicalCounts() — the canonical-count-exactness leg of this lane is unrepresentable without it.' ) } return storage.getCanonicalCounts() } /** * Snapshot the VFS root's own hidden-tier footprint. Call exactly once, * immediately after `init()` and before chapter 1 does anything — this is * the ONE baseline offset the model does not construct by hand (see the * module header for why: the root is `'system'`-tier plumbing the biography * never explicitly creates). */ export async function snapshotVfsBaseline(brain: Brainy, model: ShadowModel): Promise { const counts = await getCanonicalCountsFor(brain) model.vfsBaselineNouns = counts.nouns.all model.vfsBaselineVerbs = counts.verbs.all } /** * Record one `brain.vfs.writeFile()` call for a brand-new file at a flat * top-level path (no intermediate directories). Bumps both the noun and verb * VFS counters by one, matching the engine's actual write path exactly (see * the module header) — never call this for an overwrite of an existing path, * a nested path (which would also vivify intermediate directory nouns/edges, * a different, unmodeled shape), or the biography loses its exactness. */ export function recordVfsFileWrite(model: ShadowModel): void { model.vfsFileNouns += 1 model.vfsContainsVerbs += 1 } /** Record a fresh `add()` (or a Ch6 resurrection — `Map.set` fully replaces * whatever a prior dead entry held, which is exactly the "new metadata only" * contract a resurrection must honor). */ export function modelAdd( model: ShadowModel, id: string, entity: { type: NounType; subtype?: string; metadata: Record; visibility?: EntityVisibility } ): void { model.entities.set(id, { type: entity.type, subtype: entity.subtype, metadata: { ...entity.metadata }, visibility: entity.visibility, alive: true }) } /** Record an `update()` — merges metadata by default, matching the engine's * `merge: true` default; pass `merge: false` to mirror a full replace. */ export function modelUpdate( model: ShadowModel, id: string, patch: { metadata?: Record; merge?: boolean; visibility?: EntityVisibility } ): void { const existing = model.entities.get(id) if (!existing || !existing.alive) { throw new Error(`shadow model: update() targeted ${id}, which the model does not have alive — biography sequencing bug`) } if (patch.metadata) { existing.metadata = patch.merge === false ? { ...patch.metadata } : { ...existing.metadata, ...patch.metadata } } if (patch.visibility !== undefined) { existing.visibility = patch.visibility } } /** Record a `remove()` — marks the entity dead (entry retained, per * {@link ShadowEntity}) and cascades: every relation touching it, in either * direction, is hard-deleted from the model too (matching the engine). */ export function modelDelete(model: ShadowModel, id: string): void { const existing = model.entities.get(id) if (!existing || !existing.alive) { throw new Error(`shadow model: remove() targeted ${id}, which the model does not have alive — biography sequencing bug`) } existing.alive = false for (const [relId, rel] of model.relations) { if (rel.from === id || rel.to === id) model.relations.delete(relId) } } /** Record a `relate()` — `id` is the relation id the real call returned. */ export function modelRelate( model: ShadowModel, id: string, relation: { from: string; to: string; type: VerbType; subtype?: string; metadata?: Record } ): void { model.relations.set(id, { from: relation.from, to: relation.to, type: relation.type, subtype: relation.subtype, metadata: { ...(relation.metadata ?? {}) } }) } /** Record an `updateRelation()` metadata patch — merges by default. */ export function modelUpdateRelation( model: ShadowModel, id: string, patch: { metadata?: Record; merge?: boolean } ): void { const existing = model.relations.get(id) if (!existing) { throw new Error(`shadow model: updateRelation() targeted ${id}, which the model does not have — biography sequencing bug`) } if (patch.metadata) { existing.metadata = patch.merge === false ? { ...patch.metadata } : { ...existing.metadata, ...patch.metadata } } } /** Order-independent structural equality for plain JSON-shaped metadata. */ function deepEqual(a: unknown, b: unknown): boolean { if (a === b) return true if (typeof a !== typeof b) return false if (a === null || b === null) return a === b if (typeof a !== 'object') return false const aKeys = Object.keys(a as Record) const bKeys = Object.keys(b as Record) if (aKeys.length !== bKeys.length) return false for (const k of aKeys) { if (!deepEqual((a as Record)[k], (b as Record)[k])) return false } return true } /** One hub entity to sample for the `related()` adjacency check, plus the * verb type(s) it is known (by biography construction) to have OUT-edges * of, so the type-filtered variant is exercised too. */ export interface HubCheck { id: string typeFilters: VerbType[] } /** Options steering one `verifyChapter()` call. */ export interface VerifyOptions { /** Hub entities to sample for the `related()` adjacency check. */ hubs: HubCheck[] /** The metadata field `find()` bucket-checks against (a bare string field * every alive entity may or may not carry — distinct values present among * ALIVE model entities are discovered automatically each call, so a * chapter that moves entities across buckets is re-checked exactly). */ bucketField: string } /** * Assert the live brain agrees with the model, in full, after one chapter. * Every failure message names the chapter `label`, the id (where * applicable), and expected-vs-actual — a red here must be diagnosable from * the assertion message alone, with no need to re-read this file. */ export async function verifyChapter(brain: Brainy, model: ShadowModel, label: string, opts: VerifyOptions): Promise { // (a) + (b): every alive entity reads back exactly as modeled; every dead // entity reads as null. for (const [id, entity] of model.entities) { const live = await brain.get(id) if (entity.alive) { expect(live, `[${label}] alive entity ${id} (type=${entity.type}) must be readable via get(), got null`).not.toBeNull() const e = live! expect(e.type, `[${label}] entity ${id} .type mismatch: expected ${entity.type}, got ${e.type}`).toBe(entity.type) expect(e.subtype, `[${label}] entity ${id} .subtype mismatch: expected ${JSON.stringify(entity.subtype)}, got ${JSON.stringify(e.subtype)}`).toBe(entity.subtype) expect( e.visibility, `[${label}] entity ${id} .visibility mismatch: expected ${JSON.stringify(entity.visibility)}, got ${JSON.stringify(e.visibility)}` ).toBe(entity.visibility) const metaMatches = deepEqual(e.metadata ?? {}, entity.metadata) expect( metaMatches, `[${label}] entity ${id} .metadata mismatch: expected ${JSON.stringify(entity.metadata)}, got ${JSON.stringify(e.metadata)}` ).toBe(true) } else { expect(live, `[${label}] dead entity ${id} (type=${entity.type}) must read as null, got ${JSON.stringify(live)}`).toBeNull() } } // (c) find({ where: { : value } }) returns exactly the // model's matching alive set, per distinct value currently present. const bucketValues = new Set() for (const entity of model.entities.values()) { if (!entity.alive) continue const v = entity.metadata[opts.bucketField] if (typeof v === 'string') bucketValues.add(v) } for (const value of bucketValues) { const expectedIds = [...model.entities.entries()] .filter(([, e]) => e.alive && e.metadata[opts.bucketField] === value) .map(([id]) => id) .sort() const results = await brain.find({ where: { [opts.bucketField]: value } as Record, includeInternal: true, limit: 100000 }) const actualIds = results.map((r) => r.id).sort() expect( actualIds, `[${label}] find({ where: { ${opts.bucketField}: ${JSON.stringify(value)} } }) mismatch: expected ${expectedIds.length} ids ${JSON.stringify(expectedIds)}, got ${actualIds.length} ids ${JSON.stringify(actualIds)}` ).toEqual(expectedIds) } // (d) related(id) / related(id, { type }) for the hub sample matches the // model's adjacency exactly (out-edges — related(id) is shorthand for // { from: id }). for (const hub of opts.hubs) { const expectedAll = [...model.relations.entries()] .filter(([, r]) => r.from === hub.id) .map(([id]) => id) .sort() const liveAll = await brain.related({ from: hub.id, limit: 100000 }) const actualAllIds = liveAll.map((r) => r.id).sort() expect( actualAllIds, `[${label}] related(${hub.id}) mismatch: expected ${expectedAll.length} ids ${JSON.stringify(expectedAll)}, got ${actualAllIds.length} ids ${JSON.stringify(actualAllIds)}` ).toEqual(expectedAll) for (const typeFilter of hub.typeFilters) { const expectedTyped = [...model.relations.entries()] .filter(([, r]) => r.from === hub.id && r.type === typeFilter) .map(([id]) => id) .sort() const liveTyped = await brain.related({ from: hub.id, type: typeFilter, limit: 100000 }) const actualTypedIds = liveTyped.map((r) => r.id).sort() expect( actualTypedIds, `[${label}] related(${hub.id}, { type: '${typeFilter}' }) mismatch: expected ${expectedTyped.length} ids ${JSON.stringify(expectedTyped)}, got ${actualTypedIds.length} ids ${JSON.stringify(actualTypedIds)}` ).toEqual(expectedTyped) } } // (e) getNounCount() / getVerbCount(): PUBLIC-tier alive records // (visibility absent/'public'; 'internal' and 'system' both excluded — see // the module header) plus the VFS's own public contributions. const alivePublicNouns = [...model.entities.values()].filter((e) => e.alive && (e.visibility ?? 'public') === 'public').length const aliveVerbs = model.relations.size const expectedNounCount = alivePublicNouns + model.vfsFileNouns const expectedVerbCount = aliveVerbs + model.vfsContainsVerbs expect( await brain.getNounCount(), `[${label}] getNounCount() mismatch: expected ${expectedNounCount} (alive public entities ${alivePublicNouns} + vfs file nouns ${model.vfsFileNouns})` ).toBe(expectedNounCount) expect( await brain.getVerbCount(), `[${label}] getVerbCount() mismatch: expected ${expectedVerbCount} (alive relations ${aliveVerbs} + vfs contains verbs ${model.vfsContainsVerbs})` ).toBe(expectedVerbCount) // (f) getCanonicalCounts(): ALL-visibility scalars (every tier) equal the // model's alive totals including hidden tiers, plus the VFS's own // contributions (both file nouns/verbs AND the once-measured root // baseline). suspect must be false — every delete in this biography goes // through brain.remove(), which always proves the record it decrements. const ledger = await getCanonicalCountsFor(brain) const aliveAllNouns = [...model.entities.values()].filter((e) => e.alive).length const expectedNounsAll = aliveAllNouns + model.vfsFileNouns + model.vfsBaselineNouns const expectedVerbsAll = aliveVerbs + model.vfsContainsVerbs + model.vfsBaselineVerbs expect( ledger.nouns.all, `[${label}] getCanonicalCounts().nouns.all mismatch: expected ${expectedNounsAll} (alive incl. internal ${aliveAllNouns} + vfs file nouns ${model.vfsFileNouns} + vfs root baseline ${model.vfsBaselineNouns})` ).toBe(expectedNounsAll) expect( ledger.verbs.all, `[${label}] getCanonicalCounts().verbs.all mismatch: expected ${expectedVerbsAll} (alive relations ${aliveVerbs} + vfs contains verbs ${model.vfsContainsVerbs} + vfs root baseline ${model.vfsBaselineVerbs})` ).toBe(expectedVerbsAll) expect(ledger.nouns.counted, `[${label}] getCanonicalCounts().nouns.counted mismatch (should mirror getNounCount())`).toBe(expectedNounCount) expect(ledger.verbs.counted, `[${label}] getCanonicalCounts().verbs.counted mismatch (should mirror getVerbCount())`).toBe(expectedVerbCount) expect(ledger.suspect, `[${label}] getCanonicalCounts().suspect must be false — every delete in this biography proves its record`).toBe(false) }