Skip to content

Commit e5337de

Browse files
committed
feat(test): add property-based tests for metrics and adapters
Phase 2 complete: Property-Based Testing Metric Calculators (22 tests): - KL divergence: non-negativity, self-divergence = 0 - JS divergence: symmetry, bounded [0, log 2] - EMD: triangle inequality, symmetry, non-negativity - Degree distribution: probabilities sum to 1, bounded - Jaccard distance: bounded [0, 1], symmetry - Distribution consistency checks Graph Adapters (13 tests): - Degree = neighbor count (all nodes) - Sum of degrees = 2 * edges (undirected) - Sum of degrees = edges (directed) - Undirected symmetry: A → B implies B → A - Directed asymmetry: A → B doesn't imply B → A - Node preservation in edge lists - Degree distribution correctness Uses fast-check for property-based testing with 50-100 runs per property
1 parent d775221 commit e5337de

File tree

3 files changed

+700
-0
lines changed

3 files changed

+700
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
"eslint-plugin-sonarjs": "^3.0.5",
101101
"eslint-plugin-unicorn": "^62.0.0",
102102
"eslint-plugin-unused-imports": "^4.3.0",
103+
"fast-check": "4.5.3",
103104
"globals": "^17.0.0",
104105
"husky": "^9.1.7",
105106
"jiti": "^2.6.1",
Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
/**
2+
* Property-Based Tests for Graph Adapters
3+
*
4+
* Uses fast-check to verify graph adapter invariants hold across
5+
* randomized graph structures. Tests fundamental graph properties
6+
* that must hold regardless of implementation:
7+
*
8+
* - Degree consistency: getDegree() matches neighbor count
9+
* - Undirected symmetry: if A → B then B → A
10+
* - Directed asymmetry: A → B doesn't imply B → A
11+
* - Degree sum: sum of degrees = 2 * edges (undirected) or edges (directed)
12+
* - Node preservation: all nodes in edges appear in node list
13+
*/
14+
15+
import fc from "fast-check";
16+
import { describe, expect, it } from "vitest";
17+
18+
import { Graph } from "../../../../algorithms/graph/graph.js";
19+
import type { Edge, Node } from "../../../../algorithms/types/graph.js";
20+
import { BenchmarkGraphExpander } from "./common/benchmark-graph-expander.js";
21+
import { TestGraphExpander } from "./common/test-graph-expander.js";
22+
23+
// ============================================================================
24+
// Arbitraries (Generators for test data)
25+
// ============================================================================
26+
27+
/**
28+
* Generate a random edge list for undirected graphs.
29+
* Returns array of [source, target] tuples with node IDs as strings.
30+
*/
31+
const arbUndirectedEdges = fc
32+
.array(
33+
fc.tuple(
34+
fc.integer({ min: 1, max: 20 }),
35+
fc.integer({ min: 1, max: 20 })
36+
),
37+
{ minLength: 1, maxLength: 30 }
38+
)
39+
.map((edges) =>
40+
edges.map(([source, target]): [string, string] => [`${source}`, `${target}`])
41+
);
42+
43+
/**
44+
* Generate a random edge list for directed graphs.
45+
*/
46+
const arbDirectedEdges = arbUndirectedEdges;
47+
48+
/**
49+
* Generate a random Graph instance (undirected).
50+
*/
51+
const arbUndirectedGraph = arbUndirectedEdges.map((edges) => {
52+
const graph = new Graph<Node, Edge>(false);
53+
54+
// Collect unique node IDs
55+
const nodeIds = new Set<string>();
56+
for (const [source, target] of edges) {
57+
nodeIds.add(source);
58+
nodeIds.add(target);
59+
}
60+
61+
// Add nodes
62+
for (const id of nodeIds) {
63+
graph.addNode({ id, type: "node" });
64+
}
65+
66+
// Add edges
67+
for (const [index, [source, target]] of edges.entries()) {
68+
graph.addEdge({
69+
id: `e${index}`,
70+
source,
71+
target,
72+
type: "edge",
73+
});
74+
}
75+
76+
return graph;
77+
});
78+
79+
// ============================================================================
80+
// Property Tests: Degree Consistency (BenchmarkGraphExpander)
81+
// ============================================================================
82+
83+
describe("BenchmarkGraphExpander Degree Consistency", () => {
84+
it("getDegree matches neighbor count for all nodes", async () => {
85+
await fc.assert(
86+
fc.asyncProperty(arbUndirectedGraph, async (graph) => {
87+
const expander = new BenchmarkGraphExpander(graph, false);
88+
89+
for (const node of graph.getAllNodes()) {
90+
const neighbors = await expander.getNeighbors(node.id);
91+
const degree = expander.getDegree(node.id);
92+
expect(degree).toBe(neighbors.length);
93+
}
94+
}),
95+
{ numRuns: 50 }
96+
);
97+
});
98+
99+
it("sum of degrees equals 2 * edge count (undirected)", () => {
100+
fc.assert(
101+
fc.property(arbUndirectedGraph, (graph) => {
102+
const expander = new BenchmarkGraphExpander(graph, false);
103+
const degrees = expander.getAllDegrees();
104+
105+
let sumDegrees = 0;
106+
for (const degree of degrees.values()) {
107+
sumDegrees += degree;
108+
}
109+
110+
const edgeCount = graph.getAllEdges().length;
111+
expect(sumDegrees).toBe(2 * edgeCount);
112+
}),
113+
{ numRuns: 50 }
114+
);
115+
});
116+
117+
it("all nodes have non-negative degrees", () => {
118+
fc.assert(
119+
fc.property(arbUndirectedGraph, (graph) => {
120+
const expander = new BenchmarkGraphExpander(graph, false);
121+
const degrees = expander.getAllDegrees();
122+
123+
for (const degree of degrees.values()) {
124+
expect(degree).toBeGreaterThanOrEqual(0);
125+
}
126+
}),
127+
{ numRuns: 50 }
128+
);
129+
});
130+
});
131+
132+
// ============================================================================
133+
// Property Tests: Undirected Symmetry (BenchmarkGraphExpander)
134+
// ============================================================================
135+
136+
describe("BenchmarkGraphExpander Undirected Symmetry", () => {
137+
it("if A → B, then B → A (undirected graphs)", async () => {
138+
await fc.assert(
139+
fc.asyncProperty(arbUndirectedGraph, async (graph) => {
140+
const expander = new BenchmarkGraphExpander(graph, false);
141+
142+
// For every edge, verify symmetric neighbors
143+
for (const edge of graph.getAllEdges()) {
144+
const { source, target } = edge;
145+
146+
const sourceNeighbors = await expander.getNeighbors(source);
147+
const targetNeighbors = await expander.getNeighbors(target);
148+
149+
const sourceHasTarget = sourceNeighbors.some((n) => n.targetId === target);
150+
const targetHasSource = targetNeighbors.some((n) => n.targetId === source);
151+
152+
expect(sourceHasTarget).toBe(true);
153+
expect(targetHasSource).toBe(true);
154+
}
155+
}),
156+
{ numRuns: 30 }
157+
);
158+
});
159+
});
160+
161+
// ============================================================================
162+
// Property Tests: Degree Consistency (TestGraphExpander)
163+
// ============================================================================
164+
165+
describe("TestGraphExpander Degree Consistency", () => {
166+
it("getDegree matches neighbor count for all nodes", async () => {
167+
await fc.assert(
168+
fc.asyncProperty(arbUndirectedEdges, async (edges) => {
169+
const expander = new TestGraphExpander(edges, false);
170+
171+
for (const nodeId of expander.getAllNodeIds()) {
172+
const neighbors = await expander.getNeighbors(nodeId);
173+
const degree = expander.getDegree(nodeId);
174+
expect(degree).toBe(neighbors.length);
175+
}
176+
}),
177+
{ numRuns: 50 }
178+
);
179+
});
180+
181+
it("sum of degrees equals 2 * edge count (undirected)", () => {
182+
fc.assert(
183+
fc.property(arbUndirectedEdges, (edges) => {
184+
const expander = new TestGraphExpander(edges, false);
185+
const degrees = expander.getAllDegrees();
186+
187+
let sumDegrees = 0;
188+
for (const degree of degrees.values()) {
189+
sumDegrees += degree;
190+
}
191+
192+
expect(sumDegrees).toBe(2 * edges.length);
193+
}),
194+
{ numRuns: 50 }
195+
);
196+
});
197+
198+
it("sum of degrees equals edge count (directed)", () => {
199+
fc.assert(
200+
fc.property(arbDirectedEdges, (edges) => {
201+
const expander = new TestGraphExpander(edges, true);
202+
const degrees = expander.getAllDegrees();
203+
204+
let sumDegrees = 0;
205+
for (const degree of degrees.values()) {
206+
sumDegrees += degree;
207+
}
208+
209+
expect(sumDegrees).toBe(edges.length);
210+
}),
211+
{ numRuns: 50 }
212+
);
213+
});
214+
});
215+
216+
// ============================================================================
217+
// Property Tests: Undirected Symmetry (TestGraphExpander)
218+
// ============================================================================
219+
220+
describe("TestGraphExpander Undirected Symmetry", () => {
221+
it("if A → B, then B → A (undirected graphs)", async () => {
222+
await fc.assert(
223+
fc.asyncProperty(arbUndirectedEdges, async (edges) => {
224+
const expander = new TestGraphExpander(edges, false);
225+
226+
// For every edge, verify symmetric neighbors
227+
for (const [source, target] of edges) {
228+
const sourceNeighbors = await expander.getNeighbors(source);
229+
const targetNeighbors = await expander.getNeighbors(target);
230+
231+
const sourceHasTarget = sourceNeighbors.some((n) => n.targetId === target);
232+
const targetHasSource = targetNeighbors.some((n) => n.targetId === source);
233+
234+
expect(sourceHasTarget).toBe(true);
235+
expect(targetHasSource).toBe(true);
236+
}
237+
}),
238+
{ numRuns: 30 }
239+
);
240+
});
241+
});
242+
243+
// ============================================================================
244+
// Property Tests: Directed Asymmetry (TestGraphExpander)
245+
// ============================================================================
246+
247+
describe("TestGraphExpander Directed Asymmetry", () => {
248+
it("A → B doesn't imply B → A (directed graphs)", async () => {
249+
// Create a specific directed edge that shouldn't be symmetric
250+
const edges: Array<[string, string]> = [["1", "2"]];
251+
const expander = new TestGraphExpander(edges, true);
252+
253+
const neighbors1 = await expander.getNeighbors("1");
254+
const neighbors2 = await expander.getNeighbors("2");
255+
256+
// Node 1 should have neighbor 2
257+
expect(neighbors1.some((n) => n.targetId === "2")).toBe(true);
258+
259+
// Node 2 should NOT have neighbor 1 (directed)
260+
expect(neighbors2.some((n) => n.targetId === "1")).toBe(false);
261+
});
262+
});
263+
264+
// ============================================================================
265+
// Property Tests: Node Preservation
266+
// ============================================================================
267+
268+
describe("Node Preservation", () => {
269+
it("all nodes in edges appear in node list (BenchmarkGraphExpander)", () => {
270+
fc.assert(
271+
fc.property(arbUndirectedGraph, (graph) => {
272+
const expander = new BenchmarkGraphExpander(graph, false);
273+
const nodeIds = new Set(graph.getAllNodes().map((n) => n.id));
274+
275+
// All edges reference existing nodes
276+
for (const edge of graph.getAllEdges()) {
277+
expect(nodeIds.has(edge.source)).toBe(true);
278+
expect(nodeIds.has(edge.target)).toBe(true);
279+
}
280+
281+
// All nodes have a degree entry
282+
const degrees = expander.getAllDegrees();
283+
for (const nodeId of nodeIds) {
284+
expect(degrees.has(nodeId)).toBe(true);
285+
}
286+
}),
287+
{ numRuns: 50 }
288+
);
289+
});
290+
291+
it("all nodes in edges appear in node list (TestGraphExpander)", () => {
292+
fc.assert(
293+
fc.property(arbUndirectedEdges, (edges) => {
294+
const expander = new TestGraphExpander(edges, false);
295+
const nodeIds = new Set(expander.getAllNodeIds());
296+
297+
// All edges reference existing nodes
298+
for (const [source, target] of edges) {
299+
expect(nodeIds.has(source)).toBe(true);
300+
expect(nodeIds.has(target)).toBe(true);
301+
}
302+
}),
303+
{ numRuns: 50 }
304+
);
305+
});
306+
});
307+
308+
// ============================================================================
309+
// Property Tests: Degree Distribution Properties
310+
// ============================================================================
311+
312+
describe("Degree Distribution Properties", () => {
313+
it("degree distribution has correct node count (BenchmarkGraphExpander)", () => {
314+
fc.assert(
315+
fc.property(arbUndirectedGraph, (graph) => {
316+
const expander = new BenchmarkGraphExpander(graph, false);
317+
const distribution = expander.getDegreeDistribution();
318+
319+
// Sum of counts in distribution = total nodes
320+
let totalNodes = 0;
321+
for (const count of distribution.values()) {
322+
totalNodes += count;
323+
}
324+
325+
expect(totalNodes).toBe(graph.getAllNodes().length);
326+
}),
327+
{ numRuns: 50 }
328+
);
329+
});
330+
331+
it("degree distribution has correct node count (TestGraphExpander)", () => {
332+
fc.assert(
333+
fc.property(arbUndirectedEdges, (edges) => {
334+
const expander = new TestGraphExpander(edges, false);
335+
const degrees = expander.getAllDegrees();
336+
337+
// All nodes should have a degree entry
338+
expect(degrees.size).toBe(expander.getNodeCount());
339+
340+
// Verify each node has a non-negative degree
341+
for (const degree of degrees.values()) {
342+
expect(degree).toBeGreaterThanOrEqual(0);
343+
}
344+
}),
345+
{ numRuns: 50 }
346+
);
347+
});
348+
});

0 commit comments

Comments
 (0)