|
| 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