open-brainy/tests/lifecycle/biographyHarness.ts

390 lines
18 KiB
TypeScript
Raw Normal View History

feat(health): the gate reads the named report — reads refuse loudly, never rebuild; open serves before it returns; the ceremony door The read gate stops consulting the unnamed isReady() boolean: every provider may expose healthReport() (sync, O(1), composed from exact ledgers — HealthReport with a monotonic generation, per-invariant source ledger|deep|unledgered, missing {count, sample}), and one readiness authority (assessProviderHealth) derives the verdict. Unledgered families are UNKNOWN — never healthy, never broken; a report that throws is a loud not-ready, never a shrug. Reads at the four index choke points refuse with the typed NotReady errors, narrated once per (provider, generation) — a read NEVER starts a store walk: - the first-read lazy build retires (open builds instead, regardless of size — the ≥10k deferral and the "lazy loading on first query" branch go; disableAutoRebuild is re-meant honestly in its docs); - the verify*Live read-path rebuild triggers retire (refuse-or-serve); - the read-time consistency probe that could launch a dark rebuild from an ordinary find() retires; - repairIndex({ rebuild: ['metadata'|'graph'|'vector'] | 'all' }) is the one explicit door: rebuilds the named leg unconditionally and reports rebuilt per family; bare repairIndex() stays report-driven. test(lifecycle): the biography lane — a store's whole life, refereed tests/lifecycle/: an independent shadow model referees every read after every chapter (founding, a working day, clean restart, crash, repair, second life). Chapters 1-3 green. Chapters 4-6 assert the true contract and are marked .fails as a release-blocking finding (the kill-matrix convention): after a crash + adopt reopen the metadata index computes its 'catchup' watermark verdict and nothing consumes it — find() serves the pre-crash index while canonical and counts recover. The catchup wiring is the cure; a passing .fails will force the marker's removal. The lane runs in the integration gate (config + coverage guard).
2026-08-24 12:45:51 -07:00
/**
* @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<string, unknown>
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<string, unknown>
}
/**
* 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<string, ShadowEntity>
relations: Map<string, ShadowRelation>
/**
* `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<NonNullable<StorageAdapter['getCanonicalCounts']>> {
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<void> {
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<string, unknown>; 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<string, unknown>; 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<string, unknown> }
): 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<string, unknown>; 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<string, unknown>)
const bKeys = Object.keys(b as Record<string, unknown>)
if (aKeys.length !== bKeys.length) return false
for (const k of aKeys) {
if (!deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[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<void> {
// (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: { <bucketField>: value } }) returns exactly the
// model's matching alive set, per distinct value currently present.
const bucketValues = new Set<string>()
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<string, unknown>,
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)
}