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:
parent
4d0b64f455
commit
632d90aac5
7 changed files with 1164 additions and 11 deletions
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Reference in a new issue