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
21
RELEASES.md
21
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)
|
## 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
|
Hardening patch from a migration incident (a byte-copied store on new hardware; the service
|
||||||
|
|
|
||||||
|
|
@ -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
|
Sample-based — for a full diff, dump both with `inspect dump` and compare
|
||||||
the JSONL.
|
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
|
## Repairing a corrupted store
|
||||||
|
|
||||||
If invariants fail and you suspect index corruption, `inspect repair`
|
If invariants fail and you suspect index corruption, `inspect repair`
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ import {
|
||||||
pageRank,
|
pageRank,
|
||||||
MinHeap
|
MinHeap
|
||||||
} from './graph/analyticsFallback.js'
|
} from './graph/analyticsFallback.js'
|
||||||
|
import { runGraphAudit, type GraphAuditReport } from './graph/graphAudit.js'
|
||||||
import { createPipeline } from './streaming/pipeline.js'
|
import { createPipeline } from './streaming/pipeline.js'
|
||||||
import { configureLogger, LogLevel, prodLog } from './utils/logger.js'
|
import { configureLogger, LogLevel, prodLog } from './utils/logger.js'
|
||||||
import { setGlobalCache } from './utils/unifiedCache.js'
|
import { setGlobalCache } from './utils/unifiedCache.js'
|
||||||
|
|
@ -15489,6 +15490,88 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<GraphAuditReport> {
|
||||||
|
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<string, unknown>
|
||||||
|
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<void> {
|
async repairIndex(): Promise<void> {
|
||||||
await this.ensureInitialized()
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
|
|
||||||
214
src/graph/graphAudit.ts
Normal file
214
src/graph/graphAudit.ts
Normal file
|
|
@ -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<GraphAuditDiscrepancy & { missingEnd: 'from' | 'to' | 'both' }>
|
||||||
|
|
||||||
|
/** 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<void>
|
||||||
|
/** Stream every canonical verb record. */
|
||||||
|
eachVerb(consume: (verb: AuditVerbRecord) => void): Promise<void>
|
||||||
|
/**
|
||||||
|
* 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<Array<{ id: string }>>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<GraphAuditReport> {
|
||||||
|
const maxExamples = options.maxExamples ?? 100
|
||||||
|
const started = Date.now()
|
||||||
|
|
||||||
|
// 1. Canonical entity ids — endpoint existence oracle.
|
||||||
|
const entityIds = new Set<string>()
|
||||||
|
await deps.eachNounId((id) => entityIds.add(id))
|
||||||
|
|
||||||
|
// 2. Canonical verb walk: group by source, check endpoints, note visibility.
|
||||||
|
const canonicalVerbIds = new Set<string>()
|
||||||
|
const bySource = new Map<string, AuditVerbRecord[]>()
|
||||||
|
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<string>()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,10 @@ export type { FileVersion } from './vfs/types.js'
|
||||||
|
|
||||||
// Export diagnostics result type
|
// Export diagnostics result type
|
||||||
export type { DiagnosticsResult } from './brainy.js'
|
export type { DiagnosticsResult } from './brainy.js'
|
||||||
|
export type {
|
||||||
|
GraphAuditReport,
|
||||||
|
GraphAuditDiscrepancy
|
||||||
|
} from './graph/graphAudit.js'
|
||||||
|
|
||||||
// Export Brainy configuration and types
|
// Export Brainy configuration and types
|
||||||
export type {
|
export type {
|
||||||
|
|
|
||||||
|
|
@ -2230,6 +2230,14 @@ export abstract class BaseStorage extends BaseStorageAdapter {
|
||||||
|
|
||||||
const { limit, offset = 0, filter } = options
|
const { limit, offset = 0, filter } = options
|
||||||
const cursor = this.decodeNounWalkCursor(options.cursor)
|
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 collected: Array<{ id: string; shard: number }> = []
|
||||||
const peekCount = cursor ? limit + 1 : offset + limit + 1
|
const peekCount = cursor ? limit + 1 : offset + limit + 1
|
||||||
const startShard = cursor ? cursor.shard : 0
|
const startShard = cursor ? cursor.shard : 0
|
||||||
|
|
|
||||||
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