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

@ -0,0 +1,211 @@
/**
* @module tests/unit/brainy/graph-analytics
* @description Graph analytics surface `brain.graph.rank()` (PageRank importance),
* `brain.graph.communities()` (connected-component grouping), and
* `brain.graph.path()` (best route). These exercise the pure-TS fallbacks (no native
* GraphAccelerationProvider is registered in CI); a native provider answers the same
* INTENT (which nodes matter most / which things group / best route) and returns the
* same public shapes, cross-layer-tested against the provider.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { Brainy } from '../../../src/index.js'
import { NounType, VerbType } from '../../../src/types/graphTypes.js'
import { createTestConfig } from '../../helpers/test-factory.js'
describe('brain.graph.communities()', () => {
let brain: Brainy
let a: string, b: string, c: string, d: string, e: string, f: string
beforeEach(async () => {
brain = new Brainy(createTestConfig())
await brain.init()
// Two disjoint clusters + one isolated node:
// cluster 1: a → b → c
// cluster 2: d → e
// isolated: f (no edges)
a = await brain.add({ type: NounType.Person, data: 'A' })
b = await brain.add({ type: NounType.Person, data: 'B' })
c = await brain.add({ type: NounType.Person, data: 'C' })
d = await brain.add({ type: NounType.Person, data: 'D' })
e = await brain.add({ type: NounType.Person, data: 'E' })
f = await brain.add({ type: NounType.Person, data: 'F' })
await brain.relate({ from: a, to: b, type: VerbType.RelatedTo })
await brain.relate({ from: b, to: c, type: VerbType.RelatedTo })
await brain.relate({ from: d, to: e, type: VerbType.RelatedTo })
})
afterEach(async () => {
await brain.close()
})
it('partitions the graph into connected groups (isolated nodes are singletons)', async () => {
const { groups, count } = await brain.graph.communities()
expect(count).toBe(3)
const asSets = groups.map((g) => new Set(g))
expect(asSets).toContainEqual(new Set([a, b, c]))
expect(asSets).toContainEqual(new Set([d, e]))
expect(asSets).toContainEqual(new Set([f]))
})
it('orders groups largest-first', async () => {
const { groups } = await brain.graph.communities()
for (let i = 1; i < groups.length; i++) {
expect(groups[i - 1].length).toBeGreaterThanOrEqual(groups[i].length)
}
expect(groups[0]).toHaveLength(3) // the a-b-c cluster
})
it('directed mode groups by strong connectivity (a→b→c is NOT mutually reachable)', async () => {
// Undirected: {a,b,c} is one group. Directed: no cycle, so each is its own SCC.
const undirected = await brain.graph.communities()
expect(undirected.groups).toContainEqual(expect.arrayContaining([a, b, c]))
const directed = await brain.graph.communities({ directed: true })
// a,b,c split into singletons; d,e split too; f stays singleton → 6 groups.
expect(directed.count).toBe(6)
expect(directed.groups.every((g) => g.length === 1)).toBe(true)
})
it('directed mode keeps a cycle together as one strongly-connected community', async () => {
// x → y → z → x is a cycle: mutually reachable → one SCC even when directed.
const x = await brain.add({ type: NounType.Concept, data: 'X' })
const y = await brain.add({ type: NounType.Concept, data: 'Y' })
const z = await brain.add({ type: NounType.Concept, data: 'Z' })
await brain.relate({ from: x, to: y, type: VerbType.RelatedTo })
await brain.relate({ from: y, to: z, type: VerbType.RelatedTo })
await brain.relate({ from: z, to: x, type: VerbType.RelatedTo })
const directed = await brain.graph.communities({ directed: true })
const asSets = directed.groups.map((g) => new Set(g))
expect(asSets).toContainEqual(new Set([x, y, z]))
})
it('excludes internal-visibility edges by default; includeInternal re-links', async () => {
// A hidden edge bridging the two clusters is invisible by default.
await brain.relate({ from: c, to: d, type: VerbType.RelatedTo, visibility: 'internal' })
const hidden = await brain.graph.communities()
expect(hidden.count).toBe(3) // bridge hidden → still 3 groups
const surfaced = await brain.graph.communities({ includeInternal: true })
const asSets = surfaced.groups.map((g) => new Set(g))
expect(asSets).toContainEqual(new Set([a, b, c, d, e])) // bridge merges the clusters
})
})
describe('brain.graph.rank()', () => {
let brain: Brainy
let hub: string, a: string, b: string, c: string
beforeEach(async () => {
brain = new Brainy(createTestConfig())
await brain.init()
// a, b, c all point at hub → hub is the most "important" node.
hub = await brain.add({ type: NounType.Person, data: 'HUB' })
a = await brain.add({ type: NounType.Person, data: 'A' })
b = await brain.add({ type: NounType.Person, data: 'B' })
c = await brain.add({ type: NounType.Person, data: 'C' })
await brain.relate({ from: a, to: hub, type: VerbType.RelatedTo })
await brain.relate({ from: b, to: hub, type: VerbType.RelatedTo })
await brain.relate({ from: c, to: hub, type: VerbType.RelatedTo })
})
afterEach(async () => {
await brain.close()
})
it('ranks the most-pointed-to node highest, descending', async () => {
const ranked = await brain.graph.rank()
expect(ranked).toHaveLength(4)
expect(ranked[0].id).toBe(hub)
for (let i = 1; i < ranked.length; i++) {
expect(ranked[i - 1].score).toBeGreaterThanOrEqual(ranked[i].score)
}
})
it('scores form a probability distribution (PageRank sums to ~1)', async () => {
const ranked = await brain.graph.rank()
const total = ranked.reduce((sum, r) => sum + r.score, 0)
expect(total).toBeCloseTo(1, 5)
})
it('topK returns only the K highest', async () => {
const top1 = await brain.graph.rank({ topK: 1 })
expect(top1).toHaveLength(1)
expect(top1[0].id).toBe(hub)
})
it('returns [] on an empty graph', async () => {
const empty = new Brainy(createTestConfig())
await empty.init()
expect(await empty.graph.rank()).toEqual([])
await empty.close()
})
})
describe('brain.graph.path()', () => {
let brain: Brainy
let a: string, b: string, c: string, d: string, isolated: string
beforeEach(async () => {
brain = new Brainy(createTestConfig())
await brain.init()
// Chain a → b → c → d (light edges), plus a heavy direct shortcut a → d.
// weight is a 01 connection strength; `by:'weight'` minimizes summed weight.
a = await brain.add({ type: NounType.Person, data: 'A' })
b = await brain.add({ type: NounType.Person, data: 'B' })
c = await brain.add({ type: NounType.Person, data: 'C' })
d = await brain.add({ type: NounType.Person, data: 'D' })
isolated = await brain.add({ type: NounType.Person, data: 'ISO' })
await brain.relate({ from: a, to: b, type: VerbType.RelatedTo, weight: 0.1 })
await brain.relate({ from: b, to: c, type: VerbType.RelatedTo, weight: 0.1 })
await brain.relate({ from: c, to: d, type: VerbType.RelatedTo, weight: 0.1 })
await brain.relate({ from: a, to: d, type: VerbType.RelatedTo, weight: 0.9 }) // direct but heavy
})
afterEach(async () => {
await brain.close()
})
it('finds the fewest-hops route by default', async () => {
const route = await brain.graph.path(a, d)
expect(route).not.toBeNull()
// a → d direct edge is 1 hop, the shortest.
expect(route?.nodes).toEqual([a, d])
expect(route?.relationships).toHaveLength(1)
expect(route?.cost).toBe(1)
})
it('by:"weight" prefers the lighter multi-hop route over the heavy shortcut', async () => {
const route = await brain.graph.path(a, d, { by: 'weight' })
expect(route?.nodes).toEqual([a, b, c, d]) // 0.1+0.1+0.1 = 0.3 < 0.9
expect(route?.relationships).toHaveLength(3)
expect(route?.cost).toBeCloseTo(0.3, 6)
})
it('returns a zero-length route from a node to itself', async () => {
const route = await brain.graph.path(a, a)
expect(route).toEqual({ nodes: [a], relationships: [], cost: 0 })
})
it('returns null when the target is unreachable', async () => {
expect(await brain.graph.path(a, isolated)).toBeNull()
})
it('direction:"out" cannot walk backwards up the chain', async () => {
// d has no outgoing edges, so d → a is unreachable following only out-edges.
expect(await brain.graph.path(d, a, { direction: 'out' })).toBeNull()
// …but 'both' finds it.
const both = await brain.graph.path(d, a, { direction: 'both' })
expect(both?.nodes[0]).toBe(d)
expect(both?.nodes[both.nodes.length - 1]).toBe(a)
})
it('maxDepth abandons routes that are too long', async () => {
// The light route is 3 hops; cap at 1 hop → only the heavy direct shortcut.
const capped = await brain.graph.path(a, d, { by: 'weight', maxDepth: 1 })
expect(capped?.nodes).toEqual([a, d])
expect(capped?.cost).toBeCloseTo(0.9, 6)
})
})

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)
})
})

View file

@ -0,0 +1,163 @@
/**
* @module tests/unit/graph/analyticsFallback
* @description Direct unit tests for the pure-TS graph-analytics kernels
* (`connectedComponents`, `stronglyConnectedComponents`, `pageRank`, `MinHeap`).
* These cover the algorithmic edge cases empty graphs, dangling PageRank mass,
* nested SCCs, heap ordering without the cost of a full Brainy instance. The
* `brain.graph.*` surface is tested end-to-end in graph-analytics.test.ts.
*/
import { describe, it, expect } from 'vitest'
import {
connectedComponents,
stronglyConnectedComponents,
pageRank,
MinHeap
} from '../../../src/graph/analyticsFallback.js'
/** Group node indices that share a label into sorted sets for stable comparison. */
function groupsOf(labels: number[]): number[][] {
const byLabel = new Map<number, number[]>()
labels.forEach((label, i) => {
const arr = byLabel.get(label)
if (arr) arr.push(i)
else byLabel.set(label, [i])
})
return [...byLabel.values()].map((g) => g.sort((a, b) => a - b)).sort((a, b) => a[0] - b[0])
}
describe('connectedComponents (weakly-connected, undirected projection)', () => {
it('labels an empty graph with no components', () => {
expect(connectedComponents([])).toEqual([])
})
it('treats every isolated node as its own component', () => {
expect(groupsOf(connectedComponents([[], [], []]))).toEqual([[0], [1], [2]])
})
it('merges across edge direction (0→1, 2→1 ⇒ one group)', () => {
// 0 → 1, 2 → 1, 3 isolated
const labels = connectedComponents([[1], [], [1], []])
expect(groupsOf(labels)).toEqual([[0, 1, 2], [3]])
})
it('keeps disjoint clusters separate', () => {
// 0↔1 , 2↔3
const labels = connectedComponents([[1], [0], [3], [2]])
expect(groupsOf(labels)).toEqual([
[0, 1],
[2, 3]
])
})
})
describe('stronglyConnectedComponents (directed, Tarjan)', () => {
it('labels an empty graph with no components', () => {
expect(stronglyConnectedComponents([])).toEqual([])
})
it('splits a directed chain into singletons (no mutual reachability)', () => {
// 0 → 1 → 2
expect(groupsOf(stronglyConnectedComponents([[1], [2], []]))).toEqual([[0], [1], [2]])
})
it('collapses a directed cycle into one SCC', () => {
// 0 → 1 → 2 → 0
expect(groupsOf(stronglyConnectedComponents([[1], [2], [0]]))).toEqual([[0, 1, 2]])
})
it('separates a cycle from the acyclic tail that feeds it', () => {
// 3 → 0 → 1 → 2 → 0 : {0,1,2} is an SCC, 3 is its own SCC
const labels = stronglyConnectedComponents([[1], [2], [0], [0]])
expect(groupsOf(labels)).toEqual([[0, 1, 2], [3]])
})
it('handles two independent cycles', () => {
// 0↔1 cycle, 2↔3 cycle
const labels = stronglyConnectedComponents([
[1],
[0],
[3],
[2]
])
expect(groupsOf(labels)).toEqual([
[0, 1],
[2, 3]
])
})
it('produces contiguous community indices', () => {
const labels = stronglyConnectedComponents([[1], [2], [0], []])
const distinct = new Set(labels)
expect(Math.max(...labels)).toBe(distinct.size - 1)
})
})
describe('pageRank', () => {
it('returns an empty vector for an empty graph', () => {
expect(pageRank([]).length).toBe(0)
})
it('is uniform on a symmetric cycle', () => {
// 0 → 1 → 2 → 0 — every node identical by symmetry.
const scores = pageRank([[1], [2], [0]])
expect(scores[0]).toBeCloseTo(1 / 3, 6)
expect(scores[1]).toBeCloseTo(1 / 3, 6)
expect(scores[2]).toBeCloseTo(1 / 3, 6)
})
it('ranks a hub (high in-degree) above its sources', () => {
// 0 → 3, 1 → 3, 2 → 3 : node 3 is the hub.
const scores = pageRank([[3], [3], [3], []])
expect(scores[3]).toBeGreaterThan(scores[0])
expect(scores[3]).toBeGreaterThan(scores[1])
expect(scores[3]).toBeGreaterThan(scores[2])
})
it('redistributes dangling mass so scores sum to 1', () => {
// Node 3 is dangling (no out-edges) — its mass must not leak away.
const scores = pageRank([[3], [3], [3], []])
const total = scores.reduce((sum, s) => sum + s, 0)
expect(total).toBeCloseTo(1, 6)
})
it('sums to 1 even when every node is dangling (no edges)', () => {
const scores = pageRank([[], [], []])
const total = scores.reduce((sum, s) => sum + s, 0)
expect(total).toBeCloseTo(1, 6)
expect(scores[0]).toBeCloseTo(1 / 3, 6)
})
})
describe('MinHeap', () => {
it('pops in ascending priority order', () => {
const heap = new MinHeap<string>()
heap.push('c', 3)
heap.push('a', 1)
heap.push('b', 2)
heap.push('d', 4)
expect([heap.pop(), heap.pop(), heap.pop(), heap.pop()]).toEqual(['a', 'b', 'c', 'd'])
})
it('returns undefined when empty and tracks size', () => {
const heap = new MinHeap<number>()
expect(heap.size).toBe(0)
expect(heap.pop()).toBeUndefined()
heap.push(42, 0.5)
expect(heap.size).toBe(1)
expect(heap.pop()).toBe(42)
expect(heap.size).toBe(0)
})
it('handles interleaved push/pop correctly', () => {
const heap = new MinHeap<number>()
heap.push(5, 5)
heap.push(1, 1)
expect(heap.pop()).toBe(1)
heap.push(3, 3)
heap.push(2, 2)
expect(heap.pop()).toBe(2)
expect(heap.pop()).toBe(3)
expect(heap.pop()).toBe(5)
})
})