feat(8.0): graph analytics — brain.graph.rank / communities / path

Adds three intent-level graph reads to the `brain.graph` namespace, each
native-dispatched to the optional `@soulcraft/cor` 3.0 graph engine when present
and served from pure-TS kernels otherwise (identical public shapes, default
visibility filter respected on both paths):

- `rank(opts?)` → `{ id, score }[]` descending — importance / centrality.
  TS fallback: PageRank power-iteration with dangling-mass redistribution.
- `communities(opts?)` → `{ groups, count }` — connected grouping. TS fallback:
  union-find weakly-connected components, or iterative Tarjan SCC when
  `{ directed: true }`.
- `path(from, to, opts?)` → `{ nodes, relationships, cost } | null` — best route.
  TS fallback: BFS for fewest hops, Dijkstra (min-heap) for least summed edge
  weight (`by: 'weight'`); on-demand frontier expansion so short paths terminate
  early. `direction` / `type` / `maxDepth` filters apply.

These are intent contracts, not algorithm contracts — the question is the
promise, the algorithm is the engine's choice.

Pure kernels live in src/graph/analyticsFallback.ts (PageRank, connected
components, Tarjan SCC, MinHeap) — unit-tested in isolation. The full surface is
tested end-to-end through the TS fallback, and the native dispatch + int↔uuid
hydration paths are covered by a mock provider in graph-native-routing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
David Snelling 2026-06-23 12:32:03 -07:00
parent 4d0b64f455
commit 632d90aac5
7 changed files with 1164 additions and 11 deletions

View file

@ -19,13 +19,28 @@ import { createTestConfig } from '../../helpers/test-factory.js'
/** A stateful mock GraphAccelerationProvider; the test sets the columnar payloads. */
function makeMockAccel() {
const calls = { traverse: 0, cursorOpen: 0, cursorNext: 0, cursorClose: 0 }
const state: { calls: typeof calls; traverseResult: any; cursorChunks: any[] } = {
const calls = { traverse: 0, cursorOpen: 0, cursorNext: 0, cursorClose: 0, rank: 0, communities: 0, path: 0 }
const state: {
calls: typeof calls
traverseResult: any
cursorChunks: any[]
rankResult: any
communitiesResult: any
pathResult: any
} = {
calls,
traverseResult: null,
cursorChunks: []
cursorChunks: [],
rankResult: null,
communitiesResult: null,
pathResult: null
}
const empty = { nodeInts: new BigInt64Array(0), scores: new Float64Array(0) }
const emptyCommunities = {
nodeInts: new BigInt64Array(0),
communityIds: new Uint32Array(0),
communityCount: 0
}
const provider = {
isInitialized: true,
traverse: async () => {
@ -45,9 +60,18 @@ function makeMockAccel() {
graphCursorClose: async () => {
calls.cursorClose++
},
rank: async () => empty,
communities: async () => ({ nodeInts: new BigInt64Array(0), communityIds: new Uint32Array(0), communityCount: 0 }),
path: async () => null,
rank: async () => {
calls.rank++
return state.rankResult ?? empty
},
communities: async () => {
calls.communities++
return state.communitiesResult ?? emptyCommunities
},
path: async () => {
calls.path++
return state.pathResult
},
sample: async () => state.traverseResult,
mostConnected: async () => empty
}
@ -178,4 +202,63 @@ describe('brain.graph.* native routing + columnar hydration (native seam)', () =
expect(new Set(view.nodes.map((n) => n.id))).toEqual(new Set([x, y]))
await fb.close()
})
it('rank() routes to the native provider and hydrates node ints -> ids, order preserved', async () => {
const gei = (id: string): bigint => (brain as any).graphEntityInt(id)
// Provider returns DESCENDING scores: c, then a, then b.
mock.state.rankResult = {
nodeInts: BigInt64Array.from([gei(c), gei(a), gei(b)]),
scores: Float64Array.from([0.5, 0.3, 0.2])
}
const ranked = await brain.graph.rank()
expect(mock.state.calls.rank).toBe(1) // native path, not the TS PageRank fallback
expect(ranked.map((r) => r.id)).toEqual([c, a, b]) // int -> id, provider order kept
expect(ranked[0].score).toBe(0.5)
const top2 = await brain.graph.rank({ topK: 2 })
expect(top2.map((r) => r.id)).toEqual([c, a])
})
it('communities() routes to the native provider and buckets ids by community label', async () => {
const gei = (id: string): bigint => (brain as any).graphEntityInt(id)
// a,b in community 0; c alone in community 1.
mock.state.communitiesResult = {
nodeInts: BigInt64Array.from([gei(a), gei(b), gei(c)]),
communityIds: Uint32Array.from([0, 0, 1]),
communityCount: 2
}
const { groups, count } = await brain.graph.communities()
expect(mock.state.calls.communities).toBe(1) // native path, not the TS fallback
expect(count).toBe(2)
const asSets = groups.map((g) => new Set(g))
expect(asSets).toContainEqual(new Set([a, b]))
expect(asSets).toContainEqual(new Set([c]))
expect(groups[0]).toHaveLength(2) // largest group first
})
it('path() routes to the native provider and hydrates node + verb ints', async () => {
const gei = (id: string): bigint => (brain as any).graphEntityInt(id)
const gi = (brain as any).graphIndex
const vAB = (await gi.getVerbIdsBySource(gei(a)))[0] as bigint
const vBC = (await gi.getVerbIdsBySource(gei(b)))[0] as bigint
mock.state.pathResult = {
nodeInts: BigInt64Array.from([gei(a), gei(b), gei(c)]),
edgeVerbInts: BigInt64Array.from([vAB, vBC]),
cost: 2
}
const route = await brain.graph.path(a, c)
expect(mock.state.calls.path).toBe(1) // native path, not the TS BFS/Dijkstra fallback
expect(route?.nodes).toEqual([a, b, c]) // node ints -> ids
expect(route?.relationships).toHaveLength(2) // verb ints -> verb-id strings
expect(route?.cost).toBe(2)
})
it('path() returns null when the native provider reports unreachable', async () => {
mock.state.pathResult = null
expect(await brain.graph.path(a, c)).toBeNull()
expect(mock.state.calls.path).toBe(1)
})
})