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