/** * @module tests/unit/graph/analyticsFallback * @description Direct unit tests for the pure-TS graph-analytics kernels * (`connectedComponents`, `stronglyConnectedComponents`, `pageRank`, `MinHeap`). * These cover the algorithmic edge cases — empty graphs, dangling PageRank mass, * nested SCCs, heap ordering — without the cost of a full Brainy instance. The * `brain.graph.*` surface is tested end-to-end in graph-analytics.test.ts. */ import { describe, it, expect } from 'vitest' import { connectedComponents, stronglyConnectedComponents, pageRank, MinHeap } from '../../../src/graph/analyticsFallback.js' /** Group node indices that share a label into sorted sets for stable comparison. */ function groupsOf(labels: number[]): number[][] { const byLabel = new Map() labels.forEach((label, i) => { const arr = byLabel.get(label) if (arr) arr.push(i) else byLabel.set(label, [i]) }) return [...byLabel.values()].map((g) => g.sort((a, b) => a - b)).sort((a, b) => a[0] - b[0]) } describe('connectedComponents (weakly-connected, undirected projection)', () => { it('labels an empty graph with no components', () => { expect(connectedComponents([])).toEqual([]) }) it('treats every isolated node as its own component', () => { expect(groupsOf(connectedComponents([[], [], []]))).toEqual([[0], [1], [2]]) }) it('merges across edge direction (0→1, 2→1 ⇒ one group)', () => { // 0 → 1, 2 → 1, 3 isolated const labels = connectedComponents([[1], [], [1], []]) expect(groupsOf(labels)).toEqual([[0, 1, 2], [3]]) }) it('keeps disjoint clusters separate', () => { // 0↔1 , 2↔3 const labels = connectedComponents([[1], [0], [3], [2]]) expect(groupsOf(labels)).toEqual([ [0, 1], [2, 3] ]) }) }) describe('stronglyConnectedComponents (directed, Tarjan)', () => { it('labels an empty graph with no components', () => { expect(stronglyConnectedComponents([])).toEqual([]) }) it('splits a directed chain into singletons (no mutual reachability)', () => { // 0 → 1 → 2 expect(groupsOf(stronglyConnectedComponents([[1], [2], []]))).toEqual([[0], [1], [2]]) }) it('collapses a directed cycle into one SCC', () => { // 0 → 1 → 2 → 0 expect(groupsOf(stronglyConnectedComponents([[1], [2], [0]]))).toEqual([[0, 1, 2]]) }) it('separates a cycle from the acyclic tail that feeds it', () => { // 3 → 0 → 1 → 2 → 0 : {0,1,2} is an SCC, 3 is its own SCC const labels = stronglyConnectedComponents([[1], [2], [0], [0]]) expect(groupsOf(labels)).toEqual([[0, 1, 2], [3]]) }) it('handles two independent cycles', () => { // 0↔1 cycle, 2↔3 cycle const labels = stronglyConnectedComponents([ [1], [0], [3], [2] ]) expect(groupsOf(labels)).toEqual([ [0, 1], [2, 3] ]) }) it('produces contiguous community indices', () => { const labels = stronglyConnectedComponents([[1], [2], [0], []]) const distinct = new Set(labels) expect(Math.max(...labels)).toBe(distinct.size - 1) }) }) describe('pageRank', () => { it('returns an empty vector for an empty graph', () => { expect(pageRank([]).length).toBe(0) }) it('is uniform on a symmetric cycle', () => { // 0 → 1 → 2 → 0 — every node identical by symmetry. const scores = pageRank([[1], [2], [0]]) expect(scores[0]).toBeCloseTo(1 / 3, 6) expect(scores[1]).toBeCloseTo(1 / 3, 6) expect(scores[2]).toBeCloseTo(1 / 3, 6) }) it('ranks a hub (high in-degree) above its sources', () => { // 0 → 3, 1 → 3, 2 → 3 : node 3 is the hub. const scores = pageRank([[3], [3], [3], []]) expect(scores[3]).toBeGreaterThan(scores[0]) expect(scores[3]).toBeGreaterThan(scores[1]) expect(scores[3]).toBeGreaterThan(scores[2]) }) it('redistributes dangling mass so scores sum to 1', () => { // Node 3 is dangling (no out-edges) — its mass must not leak away. const scores = pageRank([[3], [3], [3], []]) const total = scores.reduce((sum, s) => sum + s, 0) expect(total).toBeCloseTo(1, 6) }) it('sums to 1 even when every node is dangling (no edges)', () => { const scores = pageRank([[], [], []]) const total = scores.reduce((sum, s) => sum + s, 0) expect(total).toBeCloseTo(1, 6) expect(scores[0]).toBeCloseTo(1 / 3, 6) }) }) describe('MinHeap', () => { it('pops in ascending priority order', () => { const heap = new MinHeap() heap.push('c', 3) heap.push('a', 1) heap.push('b', 2) heap.push('d', 4) expect([heap.pop(), heap.pop(), heap.pop(), heap.pop()]).toEqual(['a', 'b', 'c', 'd']) }) it('returns undefined when empty and tracks size', () => { const heap = new MinHeap() expect(heap.size).toBe(0) expect(heap.pop()).toBeUndefined() heap.push(42, 0.5) expect(heap.size).toBe(1) expect(heap.pop()).toBe(42) expect(heap.size).toBe(0) }) it('handles interleaved push/pop correctly', () => { const heap = new MinHeap() heap.push(5, 5) heap.push(1, 1) expect(heap.pop()).toBe(1) heap.push(3, 3) heap.push(2, 2) expect(heap.pop()).toBe(2) expect(heap.pop()).toBe(3) expect(heap.pop()).toBe(5) }) })