164 lines
5.2 KiB
TypeScript
164 lines
5.2 KiB
TypeScript
|
|
/**
|
||
|
|
* @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<number, number[]>()
|
||
|
|
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<string>()
|
||
|
|
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<number>()
|
||
|
|
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<number>()
|
||
|
|
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)
|
||
|
|
})
|
||
|
|
})
|