feat: brain.auditGraph() — read-only graph-truth audit
- New public API: walks every canonical relationship record, queries the same read path applications use (related() with all visibility tiers included), and classifies every discrepancy into its failure family: missingFromReads (records the read path omits — stale adjacency), danglingEndpoints (endpoint entity gone — the partial-delete scar class), readOnlyVerbIds (read-path edges with no canonical record — ghosts). Design-hidden internal/system edges are counted separately so intentional hiding is never misclassified as loss. Counts exact; example lists capped with an explicit truncatedExamples flag; loud narration on incoherence; mutates nothing. - Classification core lives in src/graph/graphAudit.ts behind injected seams (canonical walks + the end-to-end read) so the discrepancy logic is testable in isolation; brainy wires the seams to storage walks and related(). - getNounIdsWithPagination now refuses an undecodable resume cursor loudly — the third and final pagination walk brought under the 8.5.2 cursor contract. - Types exported: GraphAuditReport, GraphAuditDiscrepancy. Docs: the inspection guide gains the audit -> repair -> audit verification ritual.
This commit is contained in:
parent
07144ead86
commit
2a03fae0e2
7 changed files with 522 additions and 0 deletions
164
tests/integration/graph-audit.test.ts
Normal file
164
tests/integration/graph-audit.test.ts
Normal file
|
|
@ -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<string, string[]> // 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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue