brainy/tests/unit/brainy/graph-analytics.test.ts
David Snelling 632d90aac5 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>
2026-06-23 12:32:03 -07:00

211 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @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)
})
})