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>
211 lines
8.9 KiB
TypeScript
211 lines
8.9 KiB
TypeScript
/**
|
||
* @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 0–1 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)
|
||
})
|
||
})
|