212 lines
8.9 KiB
TypeScript
212 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)
|
|||
|
|
})
|
|||
|
|
})
|