open-brainy/tests/unit/brainy/graph-analytics.test.ts

212 lines
8.9 KiB
TypeScript
Raw Normal View History

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