diff --git a/RELEASES.md b/RELEASES.md index 540fa6fb..5ae0845d 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -10,6 +10,27 @@ Full auto-generated changelog: `CHANGELOG.md` · Releases: https://github.com/so --- +## v8.6.0 — 2026-07-17 (brain.auditGraph — the graph-truth verification instrument) + +A minor release adding one new public API, from the fleet's graph-trust program: a read-only +audit that **proves whether relationship reads return stored truth** on a given brain. + +- **`brain.auditGraph(options?)`** walks every canonical relationship record, queries the same + read path applications use (`related()` / VFS `readdir`) with all visibility tiers included, + and classifies every discrepancy into its failure family: `missingFromReads` (records the + read path omits — a stale adjacency index), `danglingEndpoints` (relationships whose endpoint + entity no longer exists — the historical partial-delete scar class), and `readOnlyVerbIds` + (read-path edges with no stored record — ghosts). Design-hidden internal/system edges are + counted separately so intentional hiding is never misclassified as loss. Counts are exact; + example lists cap at `maxExamples` with an explicit `truncatedExamples` flag; the result is + narrated loudly on incoherence. Mutates nothing — safe on a live brain. +- The operational pairing: audit → if incoherent, `repairIndex()` → audit again. A `coherent` + report after the repair is the verified all-clear. Run it after any engine upgrade, restore, + or migration. Guide: `docs/guides/inspection.md`. +- Also: `getNounIds` pagination now refuses an undecodable resume cursor loudly (the same + contract `getNouns`/`getVerbs` gained in 8.5.2 — the third and final walk brought under it). +- Types exported: `GraphAuditReport`, `GraphAuditDiscrepancy`. + ## v8.5.2 — 2026-07-17 (aggregation backfill: exception-safe, generation-verified, and loud) Hardening patch from a migration incident (a byte-copied store on new hardware; the service diff --git a/docs/guides/inspection.md b/docs/guides/inspection.md index 015c5118..240e81ae 100644 --- a/docs/guides/inspection.md +++ b/docs/guides/inspection.md @@ -166,6 +166,34 @@ brainy inspect diff /data/brain-prod /data/brain-staging Sample-based — for a full diff, dump both with `inspect dump` and compare the JSONL. +## Auditing graph-read truth + +`brain.auditGraph()` (8.6.0+) proves — or disproves — that relationship reads +return canonical truth on a given brain, without mutating anything. It walks +every stored relationship record, asks the same read path your application +uses (`related()`, VFS `readdir`) with every visibility tier included, and +classifies every discrepancy: + +```typescript +const report = await brain.auditGraph() + +report.coherent // true = related()/readdir can be trusted on this brain +report.missingFromReadsCount // records the read path omits — stale index +report.danglingEndpointsCount // relationships whose endpoint entity is gone +report.readOnlyCount // read-path edges with NO stored record — ghosts +report.visibilityHiddenCount // internal/system edges hidden by design (not a fault) +``` + +Counts are always exact; the example lists (`missingFromReads`, +`danglingEndpoints`, `readOnlyVerbIds`) are capped at `maxExamples` +(default 100) and `truncatedExamples` says so when they are. + +Run it after any engine upgrade, restore, or migration. If it reports +discrepancies, run `brain.repairIndex()` and audit again — a `coherent` +report after the repair is the verified statement that the heal worked. +Cost: one relationship-record walk plus one indexed read per distinct +source entity — safe on a live brain. + ## Repairing a corrupted store If invariants fail and you suspect index corruption, `inspect repair` diff --git a/src/brainy.ts b/src/brainy.ts index bc5cedd5..12a43086 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -46,6 +46,7 @@ import { pageRank, MinHeap } from './graph/analyticsFallback.js' +import { runGraphAudit, type GraphAuditReport } from './graph/graphAudit.js' import { createPipeline } from './streaming/pipeline.js' import { configureLogger, LogLevel, prodLog } from './utils/logger.js' import { setGlobalCache } from './utils/unifiedCache.js' @@ -15489,6 +15490,88 @@ export class Brainy implements BrainyInterface { ) } + /** + * Read-only graph-truth audit — proves (or disproves) that relationship + * reads return canonical truth on THIS brain, and classifies every + * discrepancy into its failure family: + * + * - `missingFromReads` — canonical verb records the read path omits + * (PRESENT BUT INVISIBLE: adjacency/membership staleness) + * - `danglingEndpoints` — verbs whose endpoint entity is gone (SCAR class) + * - `readOnlyVerbIds` — read-path edges with no canonical record (GHOSTS) + * + * Design-hidden edges (internal/system visibility) are counted separately — + * the audit reads with every visibility tier included, so intentional + * hiding is never misclassified as index loss. Mutates nothing; safe on a + * live brain (cost: one canonical walk + one indexed read per source). + * Run it after any engine upgrade, restore, or migration; a `coherent` + * report is the verified statement that `related()`/`readdir` can be + * trusted. If it reports discrepancies, `repairIndex()` is the sanctioned + * heal — re-run the audit afterwards to prove the repair. + * + * @param options.maxExamples - Cap per example list (counts stay exact). Default 100. + * @returns The full audit report; also narrated via logs (loud on incoherence). + * @since 8.6.0 + */ + async auditGraph(options: { maxExamples?: number } = {}): Promise { + await this.ensureInitialized({ needs: ['graph'] }) + + const PAGE = 1000 + return runGraphAudit( + { + eachNounId: async (consume) => { + let cursor: string | undefined + let offset = 0 + for (;;) { + const page = await (this.storage as unknown as { + getNounIdsWithPagination(o: { + limit: number + offset?: number + cursor?: string + }): Promise<{ ids: string[]; hasMore: boolean; nextCursor?: string }> + }).getNounIdsWithPagination( + cursor ? { limit: PAGE, cursor } : { limit: PAGE, offset } + ) + for (const id of page.ids) consume(id) + if (!page.hasMore || page.ids.length === 0) break + if (page.nextCursor) cursor = page.nextCursor + else offset += page.ids.length + } + }, + eachVerb: async (consume) => { + let cursor: string | undefined + let offset = 0 + for (;;) { + const page = await this.storage.getVerbs({ + pagination: cursor ? { limit: PAGE, cursor } : { limit: PAGE, offset } + }) + for (const verb of page.items) { + const v = verb as unknown as Record + consume({ + id: String(v.id), + type: String(v.verb ?? 'unknown'), + sourceId: String(v.sourceId), + targetId: String(v.targetId), + visibility: typeof v.visibility === 'string' ? v.visibility : undefined + }) + } + if (!page.hasMore || page.items.length === 0) break + if (page.nextCursor) cursor = page.nextCursor + else offset += page.items.length + } + }, + readRelationsFrom: async (sourceId) => + this.related({ + from: sourceId, + includeInternal: true, + includeSystem: true, + limit: 100000 + }) + }, + options + ) + } + async repairIndex(): Promise { await this.ensureInitialized() diff --git a/src/graph/graphAudit.ts b/src/graph/graphAudit.ts new file mode 100644 index 00000000..d0e44cd9 --- /dev/null +++ b/src/graph/graphAudit.ts @@ -0,0 +1,214 @@ +/** + * @module graph/graphAudit + * @description Read-only graph-truth audit — the graph sibling of `repairIndex()`'s + * diagnosis half. Verifies three layers against each other without mutating anything: + * + * 1. CANONICAL verb records (the storage walk — the source of truth) + * 2. the RELATIONSHIP READ PATH (`related()` with all visibility tiers — exactly + * what application reads like a VFS `readdir` consult) + * 3. ENTITY ENDPOINTS (does each verb's source/target still exist?) + * + * and classifies every discrepancy into the three failure families production + * incidents have shown: + * + * - `missingFromReads` — a canonical verb record the read path does NOT return + * for its source: PRESENT BUT INVISIBLE (adjacency/membership staleness). + * - `danglingEndpoints` — a canonical verb whose endpoint entity is gone: + * the SCAR class (write-path loss / partial delete). + * - `readOnlyVerbIds` — the read path returns an edge with NO canonical + * record: GHOST edges (stale index entries). + * + * `visibilityHiddenCount` is reported separately: an internal/system edge that is + * indexed and present but hidden from DEFAULT reads is working as designed — the + * audit reads with all tiers included so design-hiding is never misclassified as + * index loss. + * + * Full counts are always exact; only the example LISTS are capped (`maxExamples`) + * — a capped report says so via `truncatedExamples`, never silently. + */ + +import { prodLog } from '../utils/logger.js' + +/** One discrepant relationship, identified fully enough to inspect by hand. */ +export interface GraphAuditDiscrepancy { + verbId: string + from: string + to: string + type: string +} + +export interface GraphAuditReport { + /** True iff every discrepancy count is zero — `related()` returns canonical truth. */ + coherent: boolean + verbsInCanonical: number + entitiesInCanonical: number + /** Distinct source entities whose read path was actually consulted (coverage honesty). */ + sourcesChecked: number + + /** PRESENT BUT INVISIBLE: canonical records the read path omits. */ + missingFromReadsCount: number + missingFromReads: GraphAuditDiscrepancy[] + + /** SCAR CLASS: canonical verbs with a missing endpoint entity. */ + danglingEndpointsCount: number + danglingEndpoints: Array + + /** GHOST EDGES: read-path verb ids with no canonical record. */ + readOnlyCount: number + readOnlyVerbIds: string[] + + /** Canonical verbs hidden from DEFAULT reads by design (internal/system visibility). */ + visibilityHiddenCount: number + + /** Example lists above were capped at maxExamples; counts remain exact. */ + truncatedExamples: boolean + durationMs: number +} + +/** A canonical verb record, as the audit needs it. */ +export interface AuditVerbRecord { + id: string + type: string + sourceId: string + targetId: string + visibility?: string +} + +/** The seams the audit runs over — injected so the walk is testable in isolation. */ +export interface GraphAuditDeps { + /** Stream every canonical entity id (id-only; no per-entity reads needed). */ + eachNounId(consume: (id: string) => void): Promise + /** Stream every canonical verb record. */ + eachVerb(consume: (verb: AuditVerbRecord) => void): Promise + /** + * The END-TO-END relationship read for one source, ALL visibility tiers + * included — must be the same path application reads consult. + */ + readRelationsFrom(sourceId: string): Promise> +} + +export interface GraphAuditOptions { + /** Cap on entries per example list (counts stay exact). Default 100. */ + maxExamples?: number +} + +export async function runGraphAudit( + deps: GraphAuditDeps, + options: GraphAuditOptions = {} +): Promise { + const maxExamples = options.maxExamples ?? 100 + const started = Date.now() + + // 1. Canonical entity ids — endpoint existence oracle. + const entityIds = new Set() + await deps.eachNounId((id) => entityIds.add(id)) + + // 2. Canonical verb walk: group by source, check endpoints, note visibility. + const canonicalVerbIds = new Set() + const bySource = new Map() + let verbsInCanonical = 0 + let visibilityHiddenCount = 0 + let danglingEndpointsCount = 0 + const danglingEndpoints: GraphAuditReport['danglingEndpoints'] = [] + + await deps.eachVerb((verb) => { + verbsInCanonical++ + canonicalVerbIds.add(verb.id) + const list = bySource.get(verb.sourceId) + if (list) list.push(verb) + else bySource.set(verb.sourceId, [verb]) + + if (verb.visibility === 'internal' || verb.visibility === 'system') { + visibilityHiddenCount++ + } + + const fromMissing = !entityIds.has(verb.sourceId) + const toMissing = !entityIds.has(verb.targetId) + if (fromMissing || toMissing) { + danglingEndpointsCount++ + if (danglingEndpoints.length < maxExamples) { + danglingEndpoints.push({ + verbId: verb.id, + from: verb.sourceId, + to: verb.targetId, + type: verb.type, + missingEnd: fromMissing && toMissing ? 'both' : fromMissing ? 'from' : 'to' + }) + } + } + }) + + // 3. Per-source read-path comparison. A verb must be returned by the read + // path of ITS OWN source — the exact consult a readdir/traversal makes. + let missingFromReadsCount = 0 + const missingFromReads: GraphAuditDiscrepancy[] = [] + let readOnlyCount = 0 + const readOnlyVerbIds: string[] = [] + const readOnlySeen = new Set() + + for (const [sourceId, verbs] of bySource) { + const readIds = new Set((await deps.readRelationsFrom(sourceId)).map((r) => r.id)) + + for (const verb of verbs) { + if (!readIds.has(verb.id)) { + missingFromReadsCount++ + if (missingFromReads.length < maxExamples) { + missingFromReads.push({ + verbId: verb.id, + from: verb.sourceId, + to: verb.targetId, + type: verb.type + }) + } + } + } + + for (const readId of readIds) { + if (!canonicalVerbIds.has(readId) && !readOnlySeen.has(readId)) { + readOnlySeen.add(readId) + readOnlyCount++ + if (readOnlyVerbIds.length < maxExamples) { + readOnlyVerbIds.push(readId) + } + } + } + } + + const coherent = + missingFromReadsCount === 0 && danglingEndpointsCount === 0 && readOnlyCount === 0 + + const report: GraphAuditReport = { + coherent, + verbsInCanonical, + entitiesInCanonical: entityIds.size, + sourcesChecked: bySource.size, + missingFromReadsCount, + missingFromReads, + danglingEndpointsCount, + danglingEndpoints, + readOnlyCount, + readOnlyVerbIds, + visibilityHiddenCount, + truncatedExamples: + missingFromReadsCount > missingFromReads.length || + danglingEndpointsCount > danglingEndpoints.length || + readOnlyCount > readOnlyVerbIds.length, + durationMs: Date.now() - started + } + + if (coherent) { + prodLog.info( + `[GraphAudit] coherent: ${verbsInCanonical} verbs across ${bySource.size} sources — ` + + `the read path returns canonical truth (${report.durationMs}ms)` + ) + } else { + prodLog.warn( + `[GraphAudit] INCOHERENT: ${missingFromReadsCount} present-but-invisible, ` + + `${danglingEndpointsCount} dangling-endpoint, ${readOnlyCount} ghost ` + + `(of ${verbsInCanonical} canonical verbs, ${bySource.size} sources, ` + + `${visibilityHiddenCount} visibility-hidden by design) — ${report.durationMs}ms` + ) + } + + return report +} diff --git a/src/index.ts b/src/index.ts index bdb5ff73..d1eab40a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,10 @@ export type { FileVersion } from './vfs/types.js' // Export diagnostics result type export type { DiagnosticsResult } from './brainy.js' +export type { + GraphAuditReport, + GraphAuditDiscrepancy +} from './graph/graphAudit.js' // Export Brainy configuration and types export type { diff --git a/src/storage/baseStorage.ts b/src/storage/baseStorage.ts index 7ad00dab..6daf09c0 100644 --- a/src/storage/baseStorage.ts +++ b/src/storage/baseStorage.ts @@ -2230,6 +2230,14 @@ export abstract class BaseStorage extends BaseStorageAdapter { const { limit, offset = 0, filter } = options const cursor = this.decodeNounWalkCursor(options.cursor) + if (options.cursor && cursor === null) { + // Same law as getNouns/getVerbs: an undecodable resume token FAILS + // instead of silently restarting the walk at offset 0. + throw BrainyError.storage( + `getNounIds: invalid pagination cursor '${options.cursor}' — cannot resume this walk. ` + + `Restart it without a cursor.` + ) + } const collected: Array<{ id: string; shard: number }> = [] const peekCount = cursor ? limit + 1 : offset + limit + 1 const startShard = cursor ? cursor.shard : 0 diff --git a/tests/integration/graph-audit.test.ts b/tests/integration/graph-audit.test.ts new file mode 100644 index 00000000..74fcc96a --- /dev/null +++ b/tests/integration/graph-audit.test.ts @@ -0,0 +1,164 @@ +/** + * @module tests/integration/graph-audit + * @description brain.auditGraph() — the read-only graph-truth instrument. + * Laws under test: + * (1) a healthy brain audits COHERENT: every canonical verb is returned by the + * read path of its source, endpoints exist, no ghosts; + * (2) design-hidden edges (internal/system visibility) are counted separately + * and never misclassified as index loss; + * (3) a verb whose endpoint entity was destroyed at the storage layer (the + * scar class) is flagged as a dangling endpoint, loudly; + * (4) the classification core flags present-but-invisible and ghost edges + * exactly (exercised via injected seams — manufacturing a genuinely stale + * adjacency index end-to-end would require corrupting internals the + * public API rightly refuses to corrupt). + */ +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/index.js' +import { NounType, VerbType } from '../../src/types/graphTypes.js' +import { runGraphAudit, type AuditVerbRecord } from '../../src/graph/graphAudit.js' + +describe('brain.auditGraph() — graph-truth audit', () => { + let dir: string + let brain: any + + beforeEach(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-graph-audit-')) + brain = new Brainy({ + requireSubtype: false, + storage: { type: 'filesystem', path: dir }, + silent: true + }) + await brain.init() + }) + + afterEach(async () => { + await brain.close().catch(() => {}) + fs.rmSync(dir, { recursive: true, force: true }) + }) + + async function seedGraph(): Promise<{ ids: string[]; verbIds: string[] }> { + const ids: string[] = [] + for (let i = 0; i < 5; i++) { + ids.push( + await brain.add({ + data: `entity ${i}`, + type: NounType.Concept, + metadata: { n: i } + }) + ) + } + const verbIds: string[] = [] + verbIds.push(await brain.relate({ from: ids[0], to: ids[1], type: VerbType.RelatedTo })) + verbIds.push(await brain.relate({ from: ids[0], to: ids[2], type: VerbType.Contains })) + verbIds.push(await brain.relate({ from: ids[1], to: ids[3], type: VerbType.DependsOn })) + verbIds.push(await brain.relate({ from: ids[3], to: ids[4], type: VerbType.RelatedTo })) + return { ids, verbIds } + } + + it('audits a healthy brain as coherent, with exact counts', async () => { + const { ids } = await seedGraph() + const report = await brain.auditGraph() + + expect(report.coherent).toBe(true) + expect(report.verbsInCanonical).toBe(4) + expect(report.entitiesInCanonical).toBeGreaterThanOrEqual(ids.length) // VFS root etc. may add system nouns + expect(report.sourcesChecked).toBe(3) // ids[0], ids[1], ids[3] + expect(report.missingFromReadsCount).toBe(0) + expect(report.danglingEndpointsCount).toBe(0) + expect(report.readOnlyCount).toBe(0) + expect(report.truncatedExamples).toBe(false) + }) + + it('counts design-hidden edges separately and stays coherent', async () => { + const { ids } = await seedGraph() + await brain.relate({ + from: ids[2], + to: ids[4], + type: VerbType.RelatedTo, + visibility: 'internal' + }) + + const report = await brain.auditGraph() + expect(report.coherent).toBe(true) // hidden-by-design is NOT a discrepancy + expect(report.verbsInCanonical).toBe(5) + expect(report.visibilityHiddenCount).toBeGreaterThanOrEqual(1) + }) + + it('flags a destroyed endpoint as a dangling verb (the scar class)', async () => { + const { ids } = await seedGraph() + // Destroy ids[4] at the STORAGE layer (bypassing remove(), which would + // also delete its verbs) — the historical partial-delete scar shape. + await brain.storage.deleteNoun(ids[4]) + + const report = await brain.auditGraph() + expect(report.coherent).toBe(false) + expect(report.danglingEndpointsCount).toBe(1) + expect(report.danglingEndpoints[0].to).toBe(ids[4]) + expect(report.danglingEndpoints[0].missingEnd).toBe('to') + }) +}) + +describe('runGraphAudit classification core (injected seams)', () => { + const verb = (id: string, from: string, to: string): AuditVerbRecord => ({ + id, + type: 'relatedTo', + sourceId: from, + targetId: to + }) + + const deps = (opts: { + nouns: string[] + verbs: AuditVerbRecord[] + reads: Record // sourceId -> verb ids the read path returns + }) => ({ + eachNounId: async (consume: (id: string) => void) => { + for (const id of opts.nouns) consume(id) + }, + eachVerb: async (consume: (v: AuditVerbRecord) => void) => { + for (const v of opts.verbs) consume(v) + }, + readRelationsFrom: async (sourceId: string) => + (opts.reads[sourceId] ?? []).map((id) => ({ id })) + }) + + it('flags a canonical verb the read path omits — present but invisible', async () => { + const report = await runGraphAudit( + deps({ + nouns: ['A', 'B', 'C'], + verbs: [verb('v1', 'A', 'B'), verb('v2', 'A', 'C')], + reads: { A: ['v1'] } // v2 exists canonically but reads miss it + }) + ) + expect(report.coherent).toBe(false) + expect(report.missingFromReadsCount).toBe(1) + expect(report.missingFromReads[0].verbId).toBe('v2') + }) + + it('flags a read-path edge with no canonical record — a ghost', async () => { + const report = await runGraphAudit( + deps({ + nouns: ['A', 'B'], + verbs: [verb('v1', 'A', 'B')], + reads: { A: ['v1', 'ghost-9'] } + }) + ) + expect(report.coherent).toBe(false) + expect(report.readOnlyCount).toBe(1) + expect(report.readOnlyVerbIds).toEqual(['ghost-9']) + }) + + it('caps example lists but keeps counts exact, and says so', async () => { + const verbs = Array.from({ length: 10 }, (_, i) => verb(`v${i}`, 'A', 'B')) + const report = await runGraphAudit( + deps({ nouns: ['A', 'B'], verbs, reads: { A: [] } }), + { maxExamples: 3 } + ) + expect(report.missingFromReadsCount).toBe(10) + expect(report.missingFromReads.length).toBe(3) + expect(report.truncatedExamples).toBe(true) + }) +})