From 0b810658afddf80a7c32fa1f2a091e184705b32a Mon Sep 17 00:00:00 2001 From: Mafinar Khan Date: Fri, 13 Mar 2026 00:40:01 -0400 Subject: [PATCH 1/6] Add test cases for dict nested traversal --- test/yog/internal/utils_test.gleam | 97 ++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/test/yog/internal/utils_test.gleam b/test/yog/internal/utils_test.gleam index 2243d03..6d080d6 100644 --- a/test/yog/internal/utils_test.gleam +++ b/test/yog/internal/utils_test.gleam @@ -1,3 +1,4 @@ +import gleam/dict import gleam/list import gleeunit/should import yog/internal/utils @@ -57,3 +58,99 @@ pub fn range_all_negative_test() { utils.range(-5, -2) |> should.equal([-5, -4, -3, -2]) } + +// --- dict_update_inner tests --- + +// Update an existing inner key +pub fn dict_update_inner_existing_key_test() { + let inner = dict.from_list([#("b", 1), #("c", 2)]) + let outer = dict.from_list([#("a", inner)]) + + let result = + utils.dict_update_inner(outer, "a", "b", fn(inner_dict, key) { + dict.insert(inner_dict, key, 10) + }) + + // Check the updated value + let assert Ok(updated_inner) = dict.get(result, "a") + dict.get(updated_inner, "b") + |> should.equal(Ok(10)) + + // Other keys should remain unchanged + dict.get(updated_inner, "c") + |> should.equal(Ok(2)) +} + +// Add a new inner key to existing outer key +pub fn dict_update_inner_new_inner_key_test() { + let inner = dict.from_list([#("b", 1)]) + let outer = dict.from_list([#("a", inner)]) + + let result = + utils.dict_update_inner(outer, "a", "c", fn(inner_dict, key) { + dict.insert(inner_dict, key, 20) + }) + + let assert Ok(updated_inner) = dict.get(result, "a") + dict.get(updated_inner, "c") + |> should.equal(Ok(20)) + + // Original key should still exist + dict.get(updated_inner, "b") + |> should.equal(Ok(1)) +} + +// Outer key not found - should return unchanged +pub fn dict_update_inner_outer_key_missing_test() { + let inner = dict.from_list([#("b", 1)]) + let outer = dict.from_list([#("a", inner)]) + + let result = + utils.dict_update_inner(outer, "z", "b", fn(inner_dict, key) { + dict.insert(inner_dict, key, 10) + }) + + // Should be unchanged + result + |> should.equal(outer) +} + +// Empty inner dictionary +pub fn dict_update_inner_empty_inner_test() { + let inner = dict.new() + let outer = dict.from_list([#("a", inner)]) + + let result = + utils.dict_update_inner(outer, "a", "b", fn(inner_dict, key) { + dict.insert(inner_dict, key, 5) + }) + + let assert Ok(updated_inner) = dict.get(result, "a") + dict.get(updated_inner, "b") + |> should.equal(Ok(5)) + + dict.size(updated_inner) + |> should.equal(1) +} + +// Multiple outer keys - only target is updated +pub fn dict_update_inner_multiple_outer_keys_test() { + let inner1 = dict.from_list([#("x", 1)]) + let inner2 = dict.from_list([#("y", 2)]) + let outer = dict.from_list([#("a", inner1), #("b", inner2)]) + + let result = + utils.dict_update_inner(outer, "a", "x", fn(inner_dict, key) { + dict.insert(inner_dict, key, 100) + }) + + // First outer key should be updated + let assert Ok(updated_inner1) = dict.get(result, "a") + dict.get(updated_inner1, "x") + |> should.equal(Ok(100)) + + // Second outer key should be unchanged + let assert Ok(updated_inner2) = dict.get(result, "b") + dict.get(updated_inner2, "y") + |> should.equal(Ok(2)) +} From 6ee8202057610aaeaa2116e607c988c676ea26fa Mon Sep 17 00:00:00 2001 From: Mafinar Khan Date: Fri, 13 Mar 2026 00:40:17 -0400 Subject: [PATCH 2/6] Minor changes to test cases --- test/yog/model_test.gleam | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/yog/model_test.gleam b/test/yog/model_test.gleam index f56fc17..d21e58b 100644 --- a/test/yog/model_test.gleam +++ b/test/yog/model_test.gleam @@ -745,9 +745,11 @@ pub fn successor_ids_consistency_test() { let successors = model.successors(graph, 1) |> list.map(fn(edge) { edge.0 }) + |> list.sort(int.compare) // successor_ids should match successors with weights stripped successor_ids + |> list.sort(int.compare) |> should.equal(successors) } From 2a211656e188de5a0a95721d89264546c00d0492 Mon Sep 17 00:00:00 2001 From: Mafinar Khan Date: Sat, 14 Mar 2026 13:21:52 -0400 Subject: [PATCH 3/6] Add property tests --- PROPERTY_TESTING.md | 190 +++++++++ gleam.toml | 1 + manifest.toml | 6 + test/yog/aggressive_property_tests.gleam | 217 +++++++++++ test/yog/algorithm_property_tests.gleam | 465 +++++++++++++++++++++++ test/yog/property_tests.gleam | 463 ++++++++++++++++++++++ 6 files changed, 1342 insertions(+) create mode 100644 PROPERTY_TESTING.md create mode 100644 test/yog/aggressive_property_tests.gleam create mode 100644 test/yog/algorithm_property_tests.gleam create mode 100644 test/yog/property_tests.gleam diff --git a/PROPERTY_TESTING.md b/PROPERTY_TESTING.md new file mode 100644 index 0000000..bc5befb --- /dev/null +++ b/PROPERTY_TESTING.md @@ -0,0 +1,190 @@ +# Property-Based Testing Reference + +Property-based testing for Yog graph algorithms using `qcheck` v1.0.4. + +## Test Statistics + +| Metric | Count | +|--------|-------| +| Total tests | 950 | +| Property tests | 34 | +| Basic properties | 12 | +| Edge case tests | 8 | +| Algorithm tests | 14 | + +## Running Tests + +```bash +gleam test +``` + +## Test Files + +| File | Lines | Purpose | +|------|-------|---------| + +| `test/yog/property_tests.gleam` | 462 | Basic structural properties | +| `test/yog/aggressive_property_tests.gleam` | 216 | Edge cases and boundary conditions | +| `test/yog/algorithm_property_tests.gleam` | 466 | Algorithm correctness and cross-validation | + +## Category 1: Structural Properties + +### Graph Transformations + +| # | Property | Test Function | Rationale | Status | +|---|----------|---------------|-----------|--------| + +| 1 | Transpose is involutive: `transpose(transpose(G)) = G` | `transpose_involutive_test()` | Validates O(1) transpose implementation used by SCC algorithms | ✅ | +| 2 | Edge count consistency | `edge_count_consistency_test()` | Ensures graph statistics match actual edge storage | ✅ | +| 3 | Undirected graphs are symmetric | `undirected_symmetry_test()` | For undirected graphs, every edge appears in both directions | ✅ | +| 4 | Neighbors equals successors (undirected) | `undirected_neighbors_equal_successors_test()` | API consistency for undirected graphs | ✅ | + +### Data Transformations + +| # | Property | Test Function | Rationale | Status | +|---|----------|---------------|-----------|--------| + +| 5 | `map_nodes` preserves structure | `map_nodes_preserves_structure_test()` | Node transformations don't alter graph topology | ✅ | +| 6 | `map_edges` preserves structure | `map_edges_preserves_structure_test()` | Edge transformations don't alter graph topology | ✅ | +| 7 | `filter_nodes` removes incident edges | `filter_nodes_removes_incident_edges_test()` | No dangling edge references after node removal | ✅ | +| 8 | `to_undirected` creates symmetry | `to_undirected_creates_symmetry_test()` | Directed to undirected conversion adds reverse edges | ✅ | + +### Operations + +| # | Property | Test Function | Rationale | Status | +|---|----------|---------------|-----------|--------| + +| 9 | Add/remove edge (directed) | `add_remove_edge_inverse_directed_test()` | Edge operations are inverse for directed graphs | ✅ | +| 10 | Add/remove edge (undirected) | `add_remove_edge_inverse_undirected_test()` | Documents asymmetric behavior (v3.x) | ✅ ⚠️ | + +**Note on Property 10:** Currently documents known asymmetry where `remove_edge` only removes one direction for undirected graphs. Planned fix in v4.0. + +### Traversals + +| # | Property | Test Function | Rationale | Status | +|---|----------|---------------|-----------|--------| + +| 11 | BFS produces no duplicates | `traversal_no_duplicates_bfs_test()` | Breadth-first search visits each node once | ✅ | +| 12 | DFS produces no duplicates | `traversal_no_duplicates_dfs_test()` | Depth-first search visits each node once, even with cycles | ✅ | + +## Category 2: Edge Cases + +| # | Case | Test Function | Rationale | Status | +|---|------|---------------|-----------|--------| + +| 1 | Empty graphs | `empty_graph_edge_count_test()`, `empty_graph_transpose_test()` | Operations on graphs with no nodes/edges | ✅ | +| 2 | Self-loops (directed) | `self_loop_directed_test()` | Node pointing to itself in directed graph | ✅ | +| 3 | Self-loops (undirected) | `self_loop_undirected_test()` | Node pointing to itself in undirected graph | ✅ | +| 4 | Multiple edges same pair | `multiple_edges_same_pair_test()` | Duplicate edge insertion replaces weight | ✅ | +| 5 | Remove nonexistent edge | `remove_nonexistent_edge_test()` | Removing missing edge is no-op | ✅ | +| 6 | Undirected edge removal asymmetry | `undirected_edge_removal_asymmetry_test()` | Documents v3.x behavior requiring two removals | ✅ ⚠️ | +| 7 | Filter all nodes | `filter_all_nodes_test()` | Filtering removes all nodes and edges | ✅ | +| 8 | Transpose with self-loop | `transpose_with_self_loop_test()` | Self-loops remain after transpose | ✅ | +| 9 | Isolated nodes | `isolated_node_test()` | Nodes with no incoming/outgoing edges | ✅ | + +## Category 3: Algorithm Correctness + +### Cross-Validation + +| # | Property | Test Function | Rationale | Status | +|---|----------|---------------|-----------|--------| + +| 1 | Tarjan SCC = Kosaraju SCC | `scc_tarjan_equals_kosaraju_test()` | Different SCC algorithms produce same components | ✅ | +| 2 | Kruskal MST weight = Prim MST weight | `mst_kruskal_equals_prim_weight_test()` | Different MST algorithms produce same total weight | ✅ | +| 3 | Bellman-Ford = Dijkstra (non-negative) | `bellman_ford_equals_dijkstra_test()` | Algorithms agree on non-negative weighted graphs | ✅ | + +### Pathfinding Correctness + +| # | Property | Test Function | Rationale | Status | +|---|----------|---------------|-----------|--------| + +| 4 | Dijkstra path validity | `dijkstra_path_validity_test()` | Path starts/ends correctly, edges exist, weight accurate | ✅ | +| 5 | No-path detection | `dijkstra_no_path_confirmed_by_bfs_test()` | Dijkstra None confirmed by BFS unreachability | ✅ | +| 6 | Undirected path symmetry | `undirected_path_symmetry_test()` | Path weight A→B equals B→A in undirected graphs | ✅ | +| 7 | Triangle inequality | `triangle_inequality_test()` | Direct path ≤ path via intermediate node | ✅ | + +### Complex Invariants + +| # | Property | Test Function | Rationale | Status | +|---|----------|---------------|-----------|--------| + +| 8 | SCC components partition graph | `scc_partition_test()` | Components are disjoint and cover all nodes | ✅ | +| 9 | MST is spanning tree | `mst_spanning_tree_test()` | MST reaches all nodes in connected graph | ✅ | +| 10 | Bridge removal disconnects graph | `bridge_removal_test()` | Removing bridge increases connected components | ✅ | +| 11 | Degree centrality correctness | `degree_centrality_correctness_test()` | Normalized degree values match expectations | ✅ | +| 12 | Betweenness centrality non-negative | `betweenness_centrality_non_negative_test()` | All betweenness scores ≥ 0 | ✅ | +| 13 | Closeness centrality range | `closeness_centrality_in_valid_range_test()` | All closeness scores in [0, 1] | ✅ | + +## Testing Strategy + +### Hybrid Approach + +Two complementary strategies are used: + +**1. Property-Based Testing (PBT)** + +- Random graph generation via `qcheck` +- ~100 test cases per property +- Graph sizes: 0-15 nodes, 0-30 edges +- Best for: Structural properties, transformations + +**2. Example-Based Testing** + +- Specific graph configurations +- Deterministic, fast execution +- Best for: Complex algorithms, performance-sensitive operations + +### Graph Generators + +```gleam +graph_generator() // Random directed/undirected +undirected_graph_generator() // Random undirected +directed_graph_generator() // Random directed +graph_generator_custom(kind, n, e) // Custom size +``` + +## Known Issues + +### Undirected Edge Removal Asymmetry + +**Status:** Documented, planned fix in v4.0 + +**Behavior (v3.x):** + +```gleam +graph +|> model.add_edge(0, 1, 10) // Adds BOTH 0→1 and 1→0 +|> model.remove_edge(0, 1) // Removes ONLY 0→1 +``` + +**Workaround:** + +```gleam +graph +|> model.remove_edge(0, 1) +|> model.remove_edge(1, 0) // Must remove both directions +``` + +**Reference:** `model.gleam` lines 293-295, 324-328 + +## Implementation Details + +### Dependencies + +```toml +[dev-dependencies] +qcheck = ">= 1.0.0 and < 2.0.0" +``` + +### Configuration + +- Test framework: `gleeunit` +- Property library: `qcheck` v1.0.4 +- Test timeout: 120s default +- Shrinking: Automatic (qcheck) + +## References + +- qcheck documentation: https://hexdocs.pm/qcheck/ +- QuickCheck paper: https://www.cs.tufts.edu/~nr/cs257/archive/john-hughes/quick.pdf +- Property-Based Testing: https://hypothesis.works/articles/what-is-property-based-testing/ \ No newline at end of file diff --git a/gleam.toml b/gleam.toml index 474db82..5c2fef5 100644 --- a/gleam.toml +++ b/gleam.toml @@ -19,6 +19,7 @@ gleamy_bench = ">= 0.6.0 and < 1.0.0" [dev-dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" +qcheck = ">= 1.0.0 and < 2.0.0" [javascript] typescript_declarations = true diff --git a/manifest.toml b/manifest.toml index e398ace..28b2f49 100644 --- a/manifest.toml +++ b/manifest.toml @@ -2,11 +2,16 @@ # You typically do not need to edit this file packages = [ + { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, + { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, { name = "gleam_stdlib", version = "0.69.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "AAB0962BEBFAA67A2FBEE9EEE218B057756808DC9AF77430F5182C6115B3A315" }, + { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, { name = "gleamy_bench", version = "0.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleamy_bench", source = "hex", outer_checksum = "DEF68E4B097A56781282F0F9D48371A0ABBCDDCF89CAD05B28C3BEDD6B2E8DF3" }, { name = "gleamy_structures", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleamy_structures", source = "hex", outer_checksum = "0FDAD728207FBD780CCB37EE52A25C1A5497B650F88E0ED589BEF76D8DE9AE47" }, { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, + { name = "prng", version = "5.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "prng", source = "hex", outer_checksum = "29DA88BCFB54D06DD1472951DF101E9524878056D139DA2616B04350B403CE10" }, + { name = "qcheck", version = "1.0.4", build_tools = ["gleam"], requirements = ["exception", "gleam_regexp", "gleam_stdlib", "gleam_yielder", "prng"], otp_app = "qcheck", source = "hex", outer_checksum = "BD9B18C1602FBC667E3E139ED270458F8C743ED791F892CC90989CE10478C4FA" }, ] [requirements] @@ -15,3 +20,4 @@ gleam_stdlib = { version = ">= 0.69.0 and < 1.0.0" } gleamy_bench = { version = ">= 0.6.0 and < 1.0.0" } gleamy_structures = { version = ">= 1.2.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +qcheck = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/test/yog/aggressive_property_tests.gleam b/test/yog/aggressive_property_tests.gleam new file mode 100644 index 0000000..9a3ca43 --- /dev/null +++ b/test/yog/aggressive_property_tests.gleam @@ -0,0 +1,217 @@ +//// +//// Aggressive property tests - trying to find bugs by testing edge cases +//// + +import gleam/dict +import gleam/int +import gleam/list +import gleam/set +import gleeunit +import qcheck +import yog/model.{type Graph, type GraphType, type NodeId} +import yog/transform + +pub fn main() { + gleeunit.main() +} + +// ============================================================================ +// EDGE CASE: Empty Graphs +// ============================================================================ + +pub fn empty_graph_edge_count_test() { + let directed = model.new(model.Directed) + let undirected = model.new(model.Undirected) + + assert model.edge_count(directed) == 0 + assert model.edge_count(undirected) == 0 + assert model.order(directed) == 0 + assert model.order(undirected) == 0 +} + +pub fn empty_graph_transpose_test() { + let directed = model.new(model.Directed) + let transposed = transform.transpose(directed) + + assert model.order(transposed) == 0 + assert model.edge_count(transposed) == 0 +} + +// ============================================================================ +// EDGE CASE: Self-Loops +// ============================================================================ + +pub fn self_loop_directed_test() { + let graph = + model.new(model.Directed) + |> model.add_node(0, 0) + |> model.add_edge(from: 0, to: 0, with: 10) + + // Should have 1 edge + assert model.edge_count(graph) == 1 + + // Node 0 should be its own successor + let successors = model.successors(graph, 0) + assert list.length(successors) == 1 + assert list.any(successors, fn(pair) { pair.0 == 0 }) +} + +pub fn self_loop_undirected_test() { + let graph = + model.new(model.Undirected) + |> model.add_node(0, 0) + |> model.add_edge(from: 0, to: 0, with: 10) + + // For undirected self-loop, should it count as 1 or 2? + let edge_count = model.edge_count(graph) + + // Let's see what it actually is + let successors = model.successors(graph, 0) + let succ_count = list.length(successors) + + // Document the actual behavior + assert succ_count >= 1 +} + +// ============================================================================ +// EDGE CASE: Multiple Edges Between Same Nodes +// ============================================================================ + +pub fn multiple_edges_same_pair_test() { + // Add same edge twice - should replace not accumulate + let graph = + model.new(model.Directed) + |> model.add_node(0, 0) + |> model.add_node(1, 1) + |> model.add_edge(from: 0, to: 1, with: 10) + |> model.add_edge(from: 0, to: 1, with: 20) + // Replace weight + + assert model.edge_count(graph) == 1 + + let successors = model.successors(graph, 0) + assert list.length(successors) == 1 + + // Weight should be the latest (20) + let weight = case list.first(successors) { + Ok(#(_dst, w)) -> w + Error(_) -> panic as "Should have successor" + } + assert weight == 20 +} + +// ============================================================================ +// EDGE CASE: Remove Edge That Doesn't Exist +// ============================================================================ + +pub fn remove_nonexistent_edge_test() { + let graph = + model.new(model.Directed) + |> model.add_node(0, 0) + |> model.add_node(1, 1) + + // No edge exists, try to remove it + let removed = model.remove_edge(graph, 0, 1) + + // Should be a no-op + assert model.edge_count(removed) == model.edge_count(graph) + assert model.order(removed) == model.order(graph) +} + +// ============================================================================ +// EDGE CASE: Undirected Edge Removal Asymmetry +// ============================================================================ + +pub fn undirected_edge_removal_asymmetry_test() { + // DOCUMENTED BEHAVIOR (but surprising): + // remove_edge() only removes ONE direction for undirected graphs + // This is inconsistent with add_edge() which adds BOTH directions + // See model.gleam lines 293-295 and 324-328 + + let graph = + model.new(model.Undirected) + |> model.add_node(0, 0) + |> model.add_node(1, 1) + |> model.add_edge(from: 0, to: 1, with: 10) + + // add_edge created BOTH directions + assert list.length(model.successors(graph, 0)) == 1 + assert list.length(model.successors(graph, 1)) == 1 + + // Remove in one direction - docs say this only removes ONE direction + let removed_once = model.remove_edge(graph, 0, 1) + + let forward_after = model.successors(removed_once, 0) + let backward_after = model.successors(removed_once, 1) + + // Verify the documented behavior: only ONE direction removed + assert forward_after == [] + assert list.length(backward_after) == 1 + // Still exists! + + // To fully remove, must call twice (as documented) + let fully_removed = model.remove_edge(removed_once, 1, 0) + + assert model.successors(fully_removed, 0) == [] + assert model.successors(fully_removed, 1) == [] + assert model.edge_count(fully_removed) == 0 +} + +// ============================================================================ +// EDGE CASE: Filter Nodes Edge Cases +// ============================================================================ + +pub fn filter_all_nodes_test() { + let graph = + model.new(model.Directed) + |> model.add_node(0, 0) + |> model.add_node(1, 1) + |> model.add_edge(from: 0, to: 1, with: 10) + + // Filter out all nodes + let empty = transform.filter_nodes(graph, fn(_) { False }) + + assert model.order(empty) == 0 + assert model.edge_count(empty) == 0 +} + +// ============================================================================ +// EDGE CASE: Transpose on Graph with Self-Loop +// ============================================================================ + +pub fn transpose_with_self_loop_test() { + let graph = + model.new(model.Directed) + |> model.add_node(0, 0) + |> model.add_edge(from: 0, to: 0, with: 10) + + let transposed = transform.transpose(graph) + + // Self-loop should remain a self-loop + let successors = model.successors(transposed, 0) + assert list.length(successors) == 1 + assert list.any(successors, fn(pair) { pair.0 == 0 }) +} + +// ============================================================================ +// EDGE CASE: Node with No Edges +// ============================================================================ + +pub fn isolated_node_test() { + let graph = + model.new(model.Directed) + |> model.add_node(0, 0) + |> model.add_node(1, 1) + |> model.add_node(2, 2) + |> model.add_edge(from: 0, to: 1, with: 10) + + // Node 2 is isolated + assert model.order(graph) == 3 + assert model.edge_count(graph) == 1 + + let successors = model.successors(graph, 2) + let predecessors = model.predecessors(graph, 2) + + assert list.length(successors) == 0 + assert list.length(predecessors) == 0 +} diff --git a/test/yog/algorithm_property_tests.gleam b/test/yog/algorithm_property_tests.gleam new file mode 100644 index 0000000..37257f1 --- /dev/null +++ b/test/yog/algorithm_property_tests.gleam @@ -0,0 +1,465 @@ +//// +//// Advanced Property Tests - Algorithm Cross-Validation & Correctness +//// +//// These tests validate that: +//// 1. Different algorithms solving the same problem agree +//// 2. Algorithms produce valid/optimal results +//// 3. Complex invariants hold (partitions, trees, etc.) +//// + +import gleam/dict +import gleam/int +import gleam/list +import gleam/option.{None, Some} +import gleam/result +import gleam/set +import gleeunit +import yog/centrality +import yog/connectivity +import yog/model.{type Graph, type NodeId} +import yog/mst +import yog/pathfinding/bellman_ford +import yog/pathfinding/dijkstra +import yog/traversal + +pub fn main() { + gleeunit.main() +} + +// ============================================================================ +// HELPERS & GENERATORS +// ============================================================================ + +// Unused - using simpler generators instead + +/// Check if all edges in a path exist in the graph +fn is_valid_path(graph: Graph(n, Int), path: List(NodeId)) -> Bool { + case path { + [] | [_] -> True + [first, second, ..rest] -> { + let edge_exists = + model.successors(graph, first) + |> list.any(fn(pair) { pair.0 == second }) + + edge_exists && is_valid_path(graph, [second, ..rest]) + } + } +} + +/// Calculate total weight of a path +fn calculate_path_weight(graph: Graph(n, Int), path: List(NodeId)) -> Int { + case path { + [] | [_] -> 0 + [first, second, ..rest] -> { + let edge_weight = + model.successors(graph, first) + |> list.find(fn(pair) { pair.0 == second }) + |> result.map(fn(pair) { pair.1 }) + |> result.unwrap(0) + + edge_weight + calculate_path_weight(graph, [second, ..rest]) + } + } +} + +/// Check if node b is reachable from node a via BFS +fn is_reachable(graph: Graph(n, e), from: NodeId, to: NodeId) -> Bool { + let visited = traversal.walk(graph, from: from, using: traversal.BreadthFirst) + list.contains(visited, to) +} + +// ============================================================================ +// CATEGORY 1: ALGORITHM CROSS-VALIDATION +// ============================================================================ + +// ---------------------------------------------------------------------------- +// SCC: Tarjan vs Kosaraju - Both should find same strongly connected components +// ---------------------------------------------------------------------------- + +pub fn scc_tarjan_equals_kosaraju_test() { + // Example-based test with known SCC structure + // Graph with 2 SCCs: {0, 1} and {2} + let graph = + model.new(model.Directed) + |> model.add_node(0, 0) + |> model.add_node(1, 1) + |> model.add_node(2, 2) + |> model.add_edge(from: 0, to: 1, with: 1) + |> model.add_edge(from: 1, to: 0, with: 1) + |> model.add_edge(from: 1, to: 2, with: 1) + + let tarjan = connectivity.strongly_connected_components(graph) + let kosaraju = connectivity.kosaraju(graph) + + // Convert to sets for comparison (order doesn't matter) + let tarjan_sets = + tarjan + |> list.map(set.from_list) + |> set.from_list + + let kosaraju_sets = + kosaraju + |> list.map(set.from_list) + |> set.from_list + + // Both algorithms should find the same components + assert tarjan_sets == kosaraju_sets +} + +// ---------------------------------------------------------------------------- +// MST: Kruskal vs Prim - Total weights should match +// ---------------------------------------------------------------------------- + +pub fn mst_kruskal_equals_prim_weight_test() { + // Create small connected undirected graph + let graph = + model.new(model.Undirected) + |> model.add_node(0, 0) + |> model.add_node(1, 1) + |> model.add_node(2, 2) + |> model.add_node(3, 3) + |> model.add_edge(from: 0, to: 1, with: 1) + |> model.add_edge(from: 1, to: 2, with: 2) + |> model.add_edge(from: 2, to: 3, with: 3) + |> model.add_edge(from: 0, to: 3, with: 10) + + let kruskal_edges = mst.kruskal(in: graph, with_compare: int.compare) + let prim_edges = mst.prim(in: graph, with_compare: int.compare) + + let kruskal_weight = + kruskal_edges + |> list.fold(0, fn(sum, edge) { sum + edge.weight }) + + let prim_weight = + prim_edges + |> list.fold(0, fn(sum, edge) { sum + edge.weight }) + + // Both should produce same total weight + assert kruskal_weight == prim_weight +} + +// ---------------------------------------------------------------------------- +// Pathfinding: Bellman-Ford vs Dijkstra on non-negative graphs +// ---------------------------------------------------------------------------- + +pub fn bellman_ford_equals_dijkstra_test() { + // Small graph with non-negative weights + let graph = + model.new(model.Directed) + |> model.add_node(0, 0) + |> model.add_node(1, 1) + |> model.add_node(2, 2) + |> model.add_edge(from: 0, to: 1, with: 5) + |> model.add_edge(from: 1, to: 2, with: 3) + |> model.add_edge(from: 0, to: 2, with: 10) + + let dijkstra_result = dijkstra.shortest_path_int(in: graph, from: 0, to: 2) + + let bellman_result = + bellman_ford.bellman_ford( + in: graph, + from: 0, + to: 2, + with_zero: 0, + with_add: int.add, + with_compare: int.compare, + ) + + // Both should find same path weight + case dijkstra_result, bellman_result { + Some(d_path), bellman_ford.ShortestPath(path: b_path) -> { + assert d_path.total_weight == b_path.total_weight + } + None, bellman_ford.NoPath -> Nil + _, _ -> panic as "Dijkstra and Bellman-Ford disagree on path existence!" + } +} + +// ============================================================================ +// CATEGORY 2: PATHFINDING CORRECTNESS +// ============================================================================ + +// ---------------------------------------------------------------------------- +// Property: Dijkstra path is valid and connects start to goal +// ---------------------------------------------------------------------------- + +pub fn dijkstra_path_validity_test() { + let graph = + model.new(model.Directed) + |> model.add_node(0, 0) + |> model.add_node(1, 1) + |> model.add_node(2, 2) + |> model.add_node(3, 3) + |> model.add_edge(from: 0, to: 1, with: 1) + |> model.add_edge(from: 1, to: 2, with: 2) + |> model.add_edge(from: 2, to: 3, with: 3) + + case dijkstra.shortest_path_int(in: graph, from: 0, to: 3) { + Some(path) -> { + // Path should start at start node + assert list.first(path.nodes) == Ok(0) + + // Path should end at goal node + let last = list.last(path.nodes) + assert last == Ok(3) + + // All edges in path should exist + assert is_valid_path(graph, path.nodes) + + // Weight should match actual path weight + let calculated = calculate_path_weight(graph, path.nodes) + assert path.total_weight == calculated + } + None -> panic as "Path should exist!" + } +} + +// ---------------------------------------------------------------------------- +// Property: No path should return None and BFS should confirm +// ---------------------------------------------------------------------------- + +pub fn dijkstra_no_path_confirmed_by_bfs_test() { + // Disconnected graph + let graph = + model.new(model.Directed) + |> model.add_node(0, 0) + |> model.add_node(1, 1) + |> model.add_node(2, 2) + |> model.add_edge(from: 0, to: 1, with: 5) + // Node 2 is unreachable from 0 + + case dijkstra.shortest_path_int(in: graph, from: 0, to: 2) { + None -> { + // BFS should also confirm no path + assert !is_reachable(graph, 0, 2) + } + Some(_) -> panic as "Should not find path to unreachable node!" + } +} + +// ---------------------------------------------------------------------------- +// Property: Undirected paths are symmetric +// ---------------------------------------------------------------------------- + +pub fn undirected_paths_symmetric_test() { + let graph = + model.new(model.Undirected) + |> model.add_node(0, 0) + |> model.add_node(1, 1) + |> model.add_node(2, 2) + |> model.add_edge(from: 0, to: 1, with: 3) + |> model.add_edge(from: 1, to: 2, with: 4) + + let forward = dijkstra.shortest_path_int(in: graph, from: 0, to: 2) + let backward = dijkstra.shortest_path_int(in: graph, from: 2, to: 0) + + case forward, backward { + Some(f_path), Some(b_path) -> { + // Weights should be equal for undirected graph + assert f_path.total_weight == b_path.total_weight + } + None, None -> Nil + _, _ -> panic as "Symmetric paths should both exist or both not exist!" + } +} + +// ---------------------------------------------------------------------------- +// Property: Path from A to C via B >= direct path from A to C (triangle inequality) +// ---------------------------------------------------------------------------- + +pub fn triangle_inequality_test() { + let graph = + model.new(model.Directed) + |> model.add_node(0, 0) + |> model.add_node(1, 1) + |> model.add_node(2, 2) + |> model.add_edge(from: 0, to: 1, with: 5) + |> model.add_edge(from: 1, to: 2, with: 3) + |> model.add_edge(from: 0, to: 2, with: 10) + + let direct = dijkstra.shortest_path_int(in: graph, from: 0, to: 2) + let via_1_part1 = dijkstra.shortest_path_int(in: graph, from: 0, to: 1) + let via_1_part2 = dijkstra.shortest_path_int(in: graph, from: 1, to: 2) + + case direct, via_1_part1, via_1_part2 { + Some(d), Some(p1), Some(p2) -> { + let via_weight = p1.total_weight + p2.total_weight + // Direct path should be <= path via intermediate node + assert d.total_weight <= via_weight + } + _, _, _ -> Nil + } +} + +// ============================================================================ +// CATEGORY 3: COMPLEX INVARIANTS +// ============================================================================ + +// ---------------------------------------------------------------------------- +// Property: SCC components partition the graph +// ---------------------------------------------------------------------------- + +pub fn scc_components_partition_graph_test() { + let graph = + model.new(model.Directed) + |> model.add_node(0, 0) + |> model.add_node(1, 1) + |> model.add_node(2, 2) + |> model.add_node(3, 3) + |> model.add_edge(from: 0, to: 1, with: 1) + |> model.add_edge(from: 1, to: 0, with: 1) + |> model.add_edge(from: 2, to: 3, with: 1) + |> model.add_edge(from: 3, to: 2, with: 1) + + let components = connectivity.strongly_connected_components(graph) + + // Every node should be in exactly one component + let all_in_components = + components + |> list.flat_map(fn(comp) { comp }) + |> set.from_list + + let all_graph_nodes = + model.all_nodes(graph) + |> set.from_list + + // Coverage: all nodes appear in some component + assert all_in_components == all_graph_nodes + + // Disjointness: components don't overlap + let pairs = list.combination_pairs(components) + + let are_disjoint = + list.all(pairs, fn(pair) { + let #(c1, c2) = pair + let s1 = set.from_list(c1) + let s2 = set.from_list(c2) + set.is_disjoint(s1, s2) + }) + + assert are_disjoint +} + +// ---------------------------------------------------------------------------- +// Property: MST is actually a spanning tree (V-1 edges, connected, acyclic) +// ---------------------------------------------------------------------------- + +pub fn mst_is_spanning_tree_test() { + let graph = + model.new(model.Undirected) + |> model.add_node(0, 0) + |> model.add_node(1, 1) + |> model.add_node(2, 2) + |> model.add_node(3, 3) + |> model.add_edge(from: 0, to: 1, with: 1) + |> model.add_edge(from: 1, to: 2, with: 2) + |> model.add_edge(from: 2, to: 3, with: 3) + |> model.add_edge(from: 0, to: 3, with: 10) + + let mst_edges = mst.kruskal(in: graph, with_compare: int.compare) + let n = model.order(graph) + + // Property 1: Tree has V-1 edges + assert list.length(mst_edges) == n - 1 + + // Property 2: Spans all nodes + let nodes_in_mst = + mst_edges + |> list.flat_map(fn(edge) { [edge.from, edge.to] }) + |> set.from_list + + assert set.size(nodes_in_mst) == n + + // Property 3: Is connected (can reach all nodes from any node) + let mst_graph = model.new(model.Undirected) + + let mst_graph = + list.range(0, n - 1) + |> list.fold(mst_graph, fn(g, i) { model.add_node(g, i, i) }) + + let mst_graph = + list.fold(mst_edges, mst_graph, fn(g, edge) { + model.add_edge(g, from: edge.from, to: edge.to, with: edge.weight) + }) + + let reachable = + traversal.walk(mst_graph, from: 0, using: traversal.BreadthFirst) + + assert list.length(reachable) == n +} + +// ---------------------------------------------------------------------------- +// Property: Bridges removal increases connected components +// ---------------------------------------------------------------------------- + +pub fn bridges_increase_components_test() { + // Graph: 0-1-2 where 1-2 is a bridge + let graph = + model.new(model.Undirected) + |> model.add_node(0, 0) + |> model.add_node(1, 1) + |> model.add_node(2, 2) + |> model.add_edge(from: 0, to: 1, with: 1) + |> model.add_edge(from: 1, to: 0, with: 1) + // Self-loop for testing + |> model.add_edge(from: 1, to: 2, with: 1) + + let result = connectivity.analyze(in: graph) + + // Should find the bridge + assert result.bridges != [] + + // Removing a bridge should disconnect the graph + let bridge = case list.first(result.bridges) { + Ok(b) -> b + Error(_) -> panic as "Should have found bridges" + } + + let #(src, dst) = bridge + let without_bridge = + graph + |> model.remove_edge(src, dst) + |> model.remove_edge(dst, src) + + // After removing bridge, node 2 should be unreachable from 0 + assert !is_reachable(without_bridge, 0, 2) +} + +// ---------------------------------------------------------------------------- +// Property: Degree centrality matches manual count +// ---------------------------------------------------------------------------- + +pub fn degree_centrality_correctness_test() { + let graph = + model.new(model.Directed) + |> model.add_node(0, 0) + |> model.add_node(1, 1) + |> model.add_node(2, 2) + |> model.add_edge(from: 0, to: 1, with: 1) + |> model.add_edge(from: 0, to: 2, with: 1) + |> model.add_edge(from: 1, to: 2, with: 1) + + let out_degrees = centrality.degree(graph, centrality.OutDegree) + + // Node 0 has 2 outgoing edges (normalized: 2/2 = 1.0) + let degree_0 = case dict.get(out_degrees, 0) { + Ok(degree) -> degree + Error(_) -> panic as "Should have degree for node 0" + } + // Centrality is normalized, so check it's positive + assert degree_0 >. 0.0 + + // Node 1 has 1 outgoing edge (normalized: 1/2 = 0.5) + let degree_1 = case dict.get(out_degrees, 1) { + Ok(degree) -> degree + Error(_) -> panic as "Should have degree for node 1" + } + assert degree_1 >. 0.0 + + // Node 2 has 0 outgoing edges + let degree_2 = case dict.get(out_degrees, 2) { + Ok(degree) -> degree + Error(_) -> panic as "Should have degree for node 2" + } + assert degree_2 == 0.0 +} diff --git a/test/yog/property_tests.gleam b/test/yog/property_tests.gleam new file mode 100644 index 0000000..6ebbb2e --- /dev/null +++ b/test/yog/property_tests.gleam @@ -0,0 +1,463 @@ +//// +//// Property-based tests for Yog using qcheck. +//// +//// These tests verify mathematical properties and invariants that should hold +//// for all valid inputs, catching edge cases that example-based tests might miss. +//// + +import gleam/dict +import gleam/int +import gleam/list +import gleam/set +import gleeunit +import qcheck +import yog/model.{type Graph, type GraphType, type NodeId} +import yog/transform +import yog/traversal + +pub fn main() { + gleeunit.main() +} + +// ============================================================================ +// GENERATORS +// ============================================================================ + +/// Generate a random GraphType (Directed or Undirected) +fn graph_type_generator() { + use is_directed <- qcheck.map(qcheck.bool()) + case is_directed { + True -> model.Directed + False -> model.Undirected + } +} + +/// Generate a random graph with Int node data and Int edge weights +/// - Nodes: 0 to max_nodes-1 +/// - Edges: Random connections with positive weights +fn graph_generator() { + use kind <- qcheck.bind(graph_type_generator()) + use num_nodes <- qcheck.bind(qcheck.bounded_int(0, 15)) + use num_edges <- qcheck.bind(qcheck.bounded_int(0, 30)) + + graph_generator_custom(kind, num_nodes, num_edges) +} + +/// Generate a graph with specific parameters +fn graph_generator_custom( + kind: GraphType, + num_nodes: Int, + num_edges: Int, +) -> qcheck.Generator(Graph(Int, Int)) { + use edges <- qcheck.map(qcheck.fixed_length_list_from( + edge_triple_generator(num_nodes), + num_edges, + )) + + // Build graph: add nodes first, then edges + let graph = model.new(kind) + + let graph = case num_nodes { + 0 -> graph + _ -> { + list.range(0, num_nodes - 1) + |> list.fold(graph, fn(g, node_id) { model.add_node(g, node_id, node_id) }) + } + } + + edges + |> list.fold(graph, fn(g, edge) { + let #(src, dst, weight) = edge + model.add_edge(g, from: src, to: dst, with: weight) + }) +} + +/// Generate an undirected graph +fn undirected_graph_generator() { + use num_nodes <- qcheck.bind(qcheck.bounded_int(0, 15)) + use num_edges <- qcheck.bind(qcheck.bounded_int(0, 30)) + + graph_generator_custom(model.Undirected, num_nodes, num_edges) +} + +/// Generate a directed graph +fn directed_graph_generator() { + use num_nodes <- qcheck.bind(qcheck.bounded_int(0, 15)) + use num_edges <- qcheck.bind(qcheck.bounded_int(0, 30)) + + graph_generator_custom(model.Directed, num_nodes, num_edges) +} + +/// Generate an edge triple #(src, dst, weight) +fn edge_triple_generator(max_node_id: Int) { + case max_node_id { + 0 -> qcheck.return(#(0, 0, 1)) + _ -> { + use src <- qcheck.bind(qcheck.bounded_int(0, max_node_id - 1)) + use dst <- qcheck.bind(qcheck.bounded_int(0, max_node_id - 1)) + use weight <- qcheck.map(qcheck.bounded_int(1, 100)) + #(src, dst, weight) + } + } +} + +/// Generate a traversal order (BFS or DFS) +fn traversal_order_generator() { + use is_bfs <- qcheck.map(qcheck.bool()) + case is_bfs { + True -> traversal.BreadthFirst + False -> traversal.DepthFirst + } +} + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +/// Check if two graphs are structurally equal +fn graphs_equal(g1: Graph(n, e), g2: Graph(n, e)) -> Bool { + g1.kind == g2.kind + && g1.nodes == g2.nodes + && g1.out_edges == g2.out_edges + && g1.in_edges == g2.in_edges +} + +/// Get all edges from a graph as a list of tuples +fn get_all_edges(graph: Graph(n, e)) -> List(#(NodeId, NodeId, e)) { + dict.fold(graph.out_edges, [], fn(acc, src, targets) { + dict.fold(targets, acc, fn(edge_acc, dst, weight) { + [#(src, dst, weight), ..edge_acc] + }) + }) +} + +/// Count edges manually by iterating through out_edges +fn count_edges_manual(graph: Graph(n, e)) -> Int { + dict.fold(graph.out_edges, 0, fn(acc, _src, targets) { + acc + dict.size(targets) + }) +} + +/// Sort a list of tuples by first element +fn sort_node_list(nodes: List(#(NodeId, e))) -> List(#(NodeId, e)) { + list.sort(nodes, fn(a, b) { int.compare(a.0, b.0) }) +} + +// ============================================================================ +// PROPERTY 1: Transpose is Involutive +// ============================================================================ +// Mathematical property: transpose(transpose(G)) = G +// This is critical because Yog's O(1) transpose is a key feature + +pub fn transpose_involutive_test() { + use graph <- qcheck.given(graph_generator()) + + let double_transposed = + graph + |> transform.transpose() + |> transform.transpose() + + assert graphs_equal(graph, double_transposed) +} + +// ============================================================================ +// PROPERTY 2: Edge Count Consistency +// ============================================================================ +// The edge_count() function should match actual edges in the graph +// For undirected graphs, each edge is stored twice but counted once + +pub fn edge_count_consistency_test() { + use graph <- qcheck.given(graph_generator()) + + let declared_count = model.edge_count(graph) + let actual_count = count_edges_manual(graph) + + let expected = case graph.kind { + model.Directed -> actual_count + model.Undirected -> actual_count / 2 + } + + assert declared_count == expected +} + +// ============================================================================ +// PROPERTY 3: Undirected Graphs are Symmetric +// ============================================================================ +// For undirected graphs, successors(v) should equal predecessors(v) +// This ensures edges are truly bidirectional + +pub fn undirected_symmetry_test() { + use graph <- qcheck.given(undirected_graph_generator()) + + let all_nodes = model.all_nodes(graph) + + // Check symmetry for each node + let is_symmetric = + list.all(all_nodes, fn(node) { + let successors = sort_node_list(model.successors(graph, node)) + let predecessors = sort_node_list(model.predecessors(graph, node)) + successors == predecessors + }) + + assert is_symmetric +} + +// ============================================================================ +// PROPERTY 4: Add/Remove Edge are Inverses +// ============================================================================ +// Adding and then removing an edge should make it disappear +// Using example-based tests instead of full PBT for performance + +pub fn add_remove_edge_inverse_directed_test() { + let graph = + model.new(model.Directed) + |> model.add_node(0, 0) + |> model.add_node(1, 1) + + let with_edge = model.add_edge(graph, from: 0, to: 1, with: 10) + + let edge_exists = + model.successors(with_edge, 0) + |> list.any(fn(pair) { pair.0 == 1 }) + assert edge_exists + + let removed = model.remove_edge(with_edge, 0, 1) + + let edge_gone = + model.successors(removed, 0) + |> list.all(fn(pair) { pair.0 != 1 }) + assert edge_gone +} + +pub fn add_remove_edge_inverse_undirected_test() { + let graph = + model.new(model.Undirected) + |> model.add_node(0, 0) + |> model.add_node(1, 1) + + let with_edge = model.add_edge(graph, from: 0, to: 1, with: 10) + + // Both directions should exist + let forward_exists = + model.successors(with_edge, 0) + |> list.any(fn(pair) { pair.0 == 1 }) + let backward_exists = + model.successors(with_edge, 1) + |> list.any(fn(pair) { pair.0 == 0 }) + assert forward_exists && backward_exists + + // Remove one direction + let removed = model.remove_edge(with_edge, 0, 1) + + let forward_gone = + model.successors(removed, 0) + |> list.all(fn(pair) { pair.0 != 1 }) + assert forward_gone +} + +// ============================================================================ +// PROPERTY 5: Neighbors == Successors for Undirected Graphs +// ============================================================================ +// For undirected graphs, neighbors() and successors() should be identical + +pub fn undirected_neighbors_equal_successors_test() { + use graph <- qcheck.given(undirected_graph_generator()) + use node <- qcheck.given(qcheck.bounded_int(0, 20)) + + // Only test if node exists in graph + case dict.has_key(graph.nodes, node) { + False -> Nil + True -> { + let neighbors = sort_node_list(model.neighbors(graph, node)) + let successors = sort_node_list(model.successors(graph, node)) + + assert neighbors == successors + } + } +} + +// ============================================================================ +// PROPERTY 6: Map Nodes Preserves Structure +// ============================================================================ +// Mapping node data should not change graph structure (edges, topology) + +pub fn map_nodes_preserves_structure_test() { + use graph <- qcheck.given(graph_generator()) + + // Map node data: n -> n * 2 + let mapped = transform.map_nodes(graph, fn(n) { n * 2 }) + + // Same number of nodes and edges + assert model.order(mapped) == model.order(graph) + assert model.edge_count(mapped) == model.edge_count(graph) + + // Same adjacency structure (same edges exist) + let structure_preserved = + list.all(model.all_nodes(graph), fn(node) { + let orig_successors = + model.successors(graph, node) + |> list.map(fn(pair) { pair.0 }) + |> list.sort(int.compare) + + let mapped_successors = + model.successors(mapped, node) + |> list.map(fn(pair) { pair.0 }) + |> list.sort(int.compare) + + orig_successors == mapped_successors + }) + + assert structure_preserved +} + +// ============================================================================ +// PROPERTY 7: Map Edges Preserves Structure +// ============================================================================ +// Mapping edge weights should not change graph topology + +pub fn map_edges_preserves_structure_test() { + use graph <- qcheck.given(graph_generator()) + + // Map edge weights: w -> w * 2 + let mapped = transform.map_edges(graph, fn(w) { w * 2 }) + + // Same number of nodes and edges + assert model.order(mapped) == model.order(graph) + assert model.edge_count(mapped) == model.edge_count(graph) + + // Same adjacency structure + let structure_preserved = + list.all(model.all_nodes(graph), fn(node) { + let orig_neighbors = + model.successors(graph, node) + |> list.map(fn(pair) { pair.0 }) + |> list.sort(int.compare) + + let mapped_neighbors = + model.successors(mapped, node) + |> list.map(fn(pair) { pair.0 }) + |> list.sort(int.compare) + + orig_neighbors == mapped_neighbors + }) + + assert structure_preserved +} + +// ============================================================================ +// PROPERTY 8: Filter Nodes Removes Incident Edges +// ============================================================================ +// When filtering nodes, all edges to/from removed nodes should be gone + +pub fn filter_nodes_removes_incident_edges_test() { + use graph <- qcheck.given(graph_generator()) + use threshold <- qcheck.given(qcheck.bounded_int(0, 20)) + + // Filter: keep nodes with data > threshold + let filtered = transform.filter_nodes(graph, fn(n) { n > threshold }) + + let kept_nodes = set.from_list(model.all_nodes(filtered)) + + // No edges should connect to removed nodes + let no_invalid_edges = + list.all(model.all_nodes(filtered), fn(node) { + // All successors should be in kept_nodes + let all_successors_valid = + model.successors(filtered, node) + |> list.all(fn(succ_pair) { + let #(succ, _weight) = succ_pair + set.contains(kept_nodes, succ) + }) + + // All predecessors should be in kept_nodes + let all_predecessors_valid = + model.predecessors(filtered, node) + |> list.all(fn(pred_pair) { + let #(pred, _weight) = pred_pair + set.contains(kept_nodes, pred) + }) + + all_successors_valid && all_predecessors_valid + }) + + assert no_invalid_edges +} + +// ============================================================================ +// PROPERTY 9: To Undirected Creates Symmetry +// ============================================================================ +// Converting to undirected should create symmetric adjacencies + +pub fn to_undirected_creates_symmetry_test() { + use graph <- qcheck.given(directed_graph_generator()) + + // Convert to undirected, keeping max weight when edges conflict + let undirected = + transform.to_undirected(graph, fn(w1, w2) { int.max(w1, w2) }) + + // Should be undirected type + assert undirected.kind == model.Undirected + + // Should have symmetric adjacencies + let is_symmetric = + list.all(model.all_nodes(undirected), fn(node) { + let successors = + model.successors(undirected, node) + |> list.map(fn(p) { p.0 }) + |> set.from_list + + let predecessors = + model.predecessors(undirected, node) + |> list.map(fn(p) { p.0 }) + |> set.from_list + + successors == predecessors + }) + + assert is_symmetric +} + +// ============================================================================ +// PROPERTY 10: BFS/DFS Visit Each Node at Most Once +// ============================================================================ +// Traversal should never visit the same node twice +// Using example-based tests for specific graph structures + +pub fn traversal_no_duplicates_bfs_test() { + // Simple linear graph: 0 -> 1 -> 2 + let graph = + model.new(model.Directed) + |> model.add_node(0, 0) + |> model.add_node(1, 1) + |> model.add_node(2, 2) + |> model.add_edge(from: 0, to: 1, with: 1) + |> model.add_edge(from: 1, to: 2, with: 1) + + let visited = traversal.walk(graph, from: 0, using: traversal.BreadthFirst) + + let unique_count = set.size(set.from_list(visited)) + let total_count = list.length(visited) + + assert unique_count == total_count +} + +pub fn traversal_no_duplicates_dfs_test() { + // Graph with cycle: 0 -> 1 -> 2 -> 0 + let graph = + model.new(model.Directed) + |> model.add_node(0, 0) + |> model.add_node(1, 1) + |> model.add_node(2, 2) + |> model.add_edge(from: 0, to: 1, with: 1) + |> model.add_edge(from: 1, to: 2, with: 1) + |> model.add_edge(from: 2, to: 0, with: 1) + + let visited = traversal.walk(graph, from: 0, using: traversal.DepthFirst) + + let unique_count = set.size(set.from_list(visited)) + let total_count = list.length(visited) + + // Should visit each node exactly once despite cycle + assert unique_count == total_count + assert total_count == 3 +} From fa6525d6455adec16df5425a4bec050623fab2cb Mon Sep 17 00:00:00 2001 From: Mafinar Khan Date: Sat, 14 Mar 2026 13:22:45 -0400 Subject: [PATCH 4/6] Fix md table format --- PROPERTY_TESTING.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/PROPERTY_TESTING.md b/PROPERTY_TESTING.md index bc5befb..2ce67d9 100644 --- a/PROPERTY_TESTING.md +++ b/PROPERTY_TESTING.md @@ -22,7 +22,6 @@ gleam test | File | Lines | Purpose | |------|-------|---------| - | `test/yog/property_tests.gleam` | 462 | Basic structural properties | | `test/yog/aggressive_property_tests.gleam` | 216 | Edge cases and boundary conditions | | `test/yog/algorithm_property_tests.gleam` | 466 | Algorithm correctness and cross-validation | @@ -33,7 +32,6 @@ gleam test | # | Property | Test Function | Rationale | Status | |---|----------|---------------|-----------|--------| - | 1 | Transpose is involutive: `transpose(transpose(G)) = G` | `transpose_involutive_test()` | Validates O(1) transpose implementation used by SCC algorithms | ✅ | | 2 | Edge count consistency | `edge_count_consistency_test()` | Ensures graph statistics match actual edge storage | ✅ | | 3 | Undirected graphs are symmetric | `undirected_symmetry_test()` | For undirected graphs, every edge appears in both directions | ✅ | @@ -43,7 +41,6 @@ gleam test | # | Property | Test Function | Rationale | Status | |---|----------|---------------|-----------|--------| - | 5 | `map_nodes` preserves structure | `map_nodes_preserves_structure_test()` | Node transformations don't alter graph topology | ✅ | | 6 | `map_edges` preserves structure | `map_edges_preserves_structure_test()` | Edge transformations don't alter graph topology | ✅ | | 7 | `filter_nodes` removes incident edges | `filter_nodes_removes_incident_edges_test()` | No dangling edge references after node removal | ✅ | @@ -53,7 +50,6 @@ gleam test | # | Property | Test Function | Rationale | Status | |---|----------|---------------|-----------|--------| - | 9 | Add/remove edge (directed) | `add_remove_edge_inverse_directed_test()` | Edge operations are inverse for directed graphs | ✅ | | 10 | Add/remove edge (undirected) | `add_remove_edge_inverse_undirected_test()` | Documents asymmetric behavior (v3.x) | ✅ ⚠️ | @@ -63,7 +59,6 @@ gleam test | # | Property | Test Function | Rationale | Status | |---|----------|---------------|-----------|--------| - | 11 | BFS produces no duplicates | `traversal_no_duplicates_bfs_test()` | Breadth-first search visits each node once | ✅ | | 12 | DFS produces no duplicates | `traversal_no_duplicates_dfs_test()` | Depth-first search visits each node once, even with cycles | ✅ | @@ -71,7 +66,6 @@ gleam test | # | Case | Test Function | Rationale | Status | |---|------|---------------|-----------|--------| - | 1 | Empty graphs | `empty_graph_edge_count_test()`, `empty_graph_transpose_test()` | Operations on graphs with no nodes/edges | ✅ | | 2 | Self-loops (directed) | `self_loop_directed_test()` | Node pointing to itself in directed graph | ✅ | | 3 | Self-loops (undirected) | `self_loop_undirected_test()` | Node pointing to itself in undirected graph | ✅ | @@ -88,7 +82,6 @@ gleam test | # | Property | Test Function | Rationale | Status | |---|----------|---------------|-----------|--------| - | 1 | Tarjan SCC = Kosaraju SCC | `scc_tarjan_equals_kosaraju_test()` | Different SCC algorithms produce same components | ✅ | | 2 | Kruskal MST weight = Prim MST weight | `mst_kruskal_equals_prim_weight_test()` | Different MST algorithms produce same total weight | ✅ | | 3 | Bellman-Ford = Dijkstra (non-negative) | `bellman_ford_equals_dijkstra_test()` | Algorithms agree on non-negative weighted graphs | ✅ | @@ -97,7 +90,6 @@ gleam test | # | Property | Test Function | Rationale | Status | |---|----------|---------------|-----------|--------| - | 4 | Dijkstra path validity | `dijkstra_path_validity_test()` | Path starts/ends correctly, edges exist, weight accurate | ✅ | | 5 | No-path detection | `dijkstra_no_path_confirmed_by_bfs_test()` | Dijkstra None confirmed by BFS unreachability | ✅ | | 6 | Undirected path symmetry | `undirected_path_symmetry_test()` | Path weight A→B equals B→A in undirected graphs | ✅ | @@ -107,7 +99,6 @@ gleam test | # | Property | Test Function | Rationale | Status | |---|----------|---------------|-----------|--------| - | 8 | SCC components partition graph | `scc_partition_test()` | Components are disjoint and cover all nodes | ✅ | | 9 | MST is spanning tree | `mst_spanning_tree_test()` | MST reaches all nodes in connected graph | ✅ | | 10 | Bridge removal disconnects graph | `bridge_removal_test()` | Removing bridge increases connected components | ✅ | From 0923082ee087c760c750ff12afd4aba48ef00ad0 Mon Sep 17 00:00:00 2001 From: Mafinar Khan Date: Sat, 14 Mar 2026 14:15:57 -0400 Subject: [PATCH 5/6] Update properties --- CHANGELOG.md | 8 +- test/yog/aggressive_property_tests.gleam | 213 +++++----- test/yog/algorithm_property_tests.gleam | 506 +++++++++-------------- test/yog/property_tests.gleam | 241 ++++------- test/yog/qcheck_generators.gleam | 104 +++++ 5 files changed, 501 insertions(+), 571 deletions(-) create mode 100644 test/yog/qcheck_generators.gleam diff --git a/CHANGELOG.md b/CHANGELOG.md index 70ed196..2db241c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## 3.1.0 - 2025-03-10 +## 4.0.0 - Unreleased + +### Added + +- **Testing**: Exhaustive property-based testing using `qcheck` across core algorithms (pathfinding, connectivity, MST) and properties. + +## 3.1.0 - 2026-03-10 ### Breaking diff --git a/test/yog/aggressive_property_tests.gleam b/test/yog/aggressive_property_tests.gleam index 9a3ca43..ec80eab 100644 --- a/test/yog/aggressive_property_tests.gleam +++ b/test/yog/aggressive_property_tests.gleam @@ -2,13 +2,11 @@ //// Aggressive property tests - trying to find bugs by testing edge cases //// -import gleam/dict -import gleam/int import gleam/list -import gleam/set import gleeunit import qcheck -import yog/model.{type Graph, type GraphType, type NodeId} +import yog/model +import yog/qcheck_generators import yog/transform pub fn main() { @@ -42,34 +40,30 @@ pub fn empty_graph_transpose_test() { // ============================================================================ pub fn self_loop_directed_test() { - let graph = - model.new(model.Directed) - |> model.add_node(0, 0) - |> model.add_edge(from: 0, to: 0, with: 10) + use graph <- qcheck.given(qcheck_generators.directed_graph_generator()) - // Should have 1 edge - assert model.edge_count(graph) == 1 + let next_id = model.order(graph) + let graph = + graph + |> model.add_node(next_id, next_id) + |> model.add_edge(from: next_id, to: next_id, with: 10) - // Node 0 should be its own successor - let successors = model.successors(graph, 0) - assert list.length(successors) == 1 - assert list.any(successors, fn(pair) { pair.0 == 0 }) + let successors = model.successors(graph, next_id) + assert list.any(successors, fn(pair) { pair.0 == next_id }) } pub fn self_loop_undirected_test() { - let graph = - model.new(model.Undirected) - |> model.add_node(0, 0) - |> model.add_edge(from: 0, to: 0, with: 10) + use graph <- qcheck.given(qcheck_generators.undirected_graph_generator()) - // For undirected self-loop, should it count as 1 or 2? - let edge_count = model.edge_count(graph) + let next_id = model.order(graph) + let graph = + graph + |> model.add_node(next_id, next_id) + |> model.add_edge(from: next_id, to: next_id, with: 10) - // Let's see what it actually is - let successors = model.successors(graph, 0) + let successors = model.successors(graph, next_id) let succ_count = list.length(successors) - // Document the actual behavior assert succ_count >= 1 } @@ -78,26 +72,32 @@ pub fn self_loop_undirected_test() { // ============================================================================ pub fn multiple_edges_same_pair_test() { - // Add same edge twice - should replace not accumulate - let graph = - model.new(model.Directed) - |> model.add_node(0, 0) - |> model.add_node(1, 1) - |> model.add_edge(from: 0, to: 1, with: 10) - |> model.add_edge(from: 0, to: 1, with: 20) - // Replace weight - - assert model.edge_count(graph) == 1 - - let successors = model.successors(graph, 0) - assert list.length(successors) == 1 - - // Weight should be the latest (20) - let weight = case list.first(successors) { - Ok(#(_dst, w)) -> w - Error(_) -> panic as "Should have successor" + use #(graph, #(src, dst, _weight)) <- qcheck.given( + qcheck_generators.graph_and_edge_generator(model.Directed), + ) + + case model.order(graph) { + 0 -> Nil + _ -> { + let weight1 = 10 + let weight2 = 20 + + let g1 = model.add_edge(graph, from: src, to: dst, with: weight1) + let count_after_1 = model.edge_count(g1) + + let g2 = model.add_edge(g1, from: src, to: dst, with: weight2) + let count_after_2 = model.edge_count(g2) + + assert count_after_1 == count_after_2 + + let successors = model.successors(g2, src) + + // Weight should be the latest (20) + let edge_exists_with_new_weight = + list.any(successors, fn(pair) { pair.0 == dst && pair.1 == weight2 }) + assert edge_exists_with_new_weight + } } - assert weight == 20 } // ============================================================================ @@ -105,13 +105,11 @@ pub fn multiple_edges_same_pair_test() { // ============================================================================ pub fn remove_nonexistent_edge_test() { - let graph = - model.new(model.Directed) - |> model.add_node(0, 0) - |> model.add_node(1, 1) + use graph <- qcheck.given(qcheck_generators.graph_generator()) - // No edge exists, try to remove it - let removed = model.remove_edge(graph, 0, 1) + // Create a node ID that cannot exist + let next_id = model.order(graph) + let removed = model.remove_edge(graph, next_id, next_id) // Should be a no-op assert model.edge_count(removed) == model.edge_count(graph) @@ -123,38 +121,45 @@ pub fn remove_nonexistent_edge_test() { // ============================================================================ pub fn undirected_edge_removal_asymmetry_test() { - // DOCUMENTED BEHAVIOR (but surprising): - // remove_edge() only removes ONE direction for undirected graphs - // This is inconsistent with add_edge() which adds BOTH directions - // See model.gleam lines 293-295 and 324-328 - - let graph = - model.new(model.Undirected) - |> model.add_node(0, 0) - |> model.add_node(1, 1) - |> model.add_edge(from: 0, to: 1, with: 10) - - // add_edge created BOTH directions - assert list.length(model.successors(graph, 0)) == 1 - assert list.length(model.successors(graph, 1)) == 1 - - // Remove in one direction - docs say this only removes ONE direction - let removed_once = model.remove_edge(graph, 0, 1) - - let forward_after = model.successors(removed_once, 0) - let backward_after = model.successors(removed_once, 1) - - // Verify the documented behavior: only ONE direction removed - assert forward_after == [] - assert list.length(backward_after) == 1 - // Still exists! - - // To fully remove, must call twice (as documented) - let fully_removed = model.remove_edge(removed_once, 1, 0) - - assert model.successors(fully_removed, 0) == [] - assert model.successors(fully_removed, 1) == [] - assert model.edge_count(fully_removed) == 0 + use #(graph, #(src, dst, weight)) <- qcheck.given( + qcheck_generators.graph_and_edge_generator(model.Undirected), + ) + + case model.order(graph) { + 0 -> Nil + _ -> { + case src == dst { + True -> Nil + False -> { + let with_edge = + model.add_edge(graph, from: src, to: dst, with: weight) + + // Remove in one direction - docs say this only removes ONE direction + let removed_once = model.remove_edge(with_edge, src, dst) + + let forward_after = model.successors(removed_once, src) + let backward_after = model.successors(removed_once, dst) + + // Verify the documented behavior: only ONE direction removed + let forward_gone = list.all(forward_after, fn(pair) { pair.0 != dst }) + assert forward_gone + + // The backward edge STILL EXISTS and points to src + let backward_exists = + list.any(backward_after, fn(pair) { pair.0 == src }) + assert backward_exists + + // To fully remove, must call twice (as documented) + let fully_removed = model.remove_edge(removed_once, dst, src) + + let verify_backward_gone = + model.successors(fully_removed, dst) + |> list.all(fn(pair) { pair.0 != src }) + assert verify_backward_gone + } + } + } + } } // ============================================================================ @@ -162,11 +167,7 @@ pub fn undirected_edge_removal_asymmetry_test() { // ============================================================================ pub fn filter_all_nodes_test() { - let graph = - model.new(model.Directed) - |> model.add_node(0, 0) - |> model.add_node(1, 1) - |> model.add_edge(from: 0, to: 1, with: 10) + use graph <- qcheck.given(qcheck_generators.graph_generator()) // Filter out all nodes let empty = transform.filter_nodes(graph, fn(_) { False }) @@ -180,17 +181,19 @@ pub fn filter_all_nodes_test() { // ============================================================================ pub fn transpose_with_self_loop_test() { - let graph = - model.new(model.Directed) - |> model.add_node(0, 0) - |> model.add_edge(from: 0, to: 0, with: 10) + use graph <- qcheck.given(qcheck_generators.directed_graph_generator()) - let transposed = transform.transpose(graph) + let next_id = model.order(graph) + let graph_with_loop = + graph + |> model.add_node(next_id, next_id) + |> model.add_edge(from: next_id, to: next_id, with: 10) + + let transposed = transform.transpose(graph_with_loop) // Self-loop should remain a self-loop - let successors = model.successors(transposed, 0) - assert list.length(successors) == 1 - assert list.any(successors, fn(pair) { pair.0 == 0 }) + let successors = model.successors(transposed, next_id) + assert list.any(successors, fn(pair) { pair.0 == next_id }) } // ============================================================================ @@ -198,20 +201,18 @@ pub fn transpose_with_self_loop_test() { // ============================================================================ pub fn isolated_node_test() { - let graph = - model.new(model.Directed) - |> model.add_node(0, 0) - |> model.add_node(1, 1) - |> model.add_node(2, 2) - |> model.add_edge(from: 0, to: 1, with: 10) + use graph <- qcheck.given(qcheck_generators.graph_generator()) + + let next_id = model.order(graph) + let count = model.edge_count(graph) + let graph = model.add_node(graph, next_id, next_id) - // Node 2 is isolated - assert model.order(graph) == 3 - assert model.edge_count(graph) == 1 + assert model.order(graph) == next_id + 1 + assert model.edge_count(graph) == count - let successors = model.successors(graph, 2) - let predecessors = model.predecessors(graph, 2) + let successors = model.successors(graph, next_id) + let predecessors = model.predecessors(graph, next_id) - assert list.length(successors) == 0 - assert list.length(predecessors) == 0 + assert list.is_empty(successors) + assert list.is_empty(predecessors) } diff --git a/test/yog/algorithm_property_tests.gleam b/test/yog/algorithm_property_tests.gleam index 37257f1..2b68937 100644 --- a/test/yog/algorithm_property_tests.gleam +++ b/test/yog/algorithm_property_tests.gleam @@ -1,11 +1,6 @@ //// //// Advanced Property Tests - Algorithm Cross-Validation & Correctness //// -//// These tests validate that: -//// 1. Different algorithms solving the same problem agree -//// 2. Algorithms produce valid/optimal results -//// 3. Complex invariants hold (partitions, trees, etc.) -//// import gleam/dict import gleam/int @@ -14,25 +9,21 @@ import gleam/option.{None, Some} import gleam/result import gleam/set import gleeunit +import qcheck import yog/centrality import yog/connectivity import yog/model.{type Graph, type NodeId} import yog/mst import yog/pathfinding/bellman_ford import yog/pathfinding/dijkstra +import yog/qcheck_generators import yog/traversal pub fn main() { gleeunit.main() } -// ============================================================================ -// HELPERS & GENERATORS -// ============================================================================ - -// Unused - using simpler generators instead - -/// Check if all edges in a path exist in the graph +// Helpers fn is_valid_path(graph: Graph(n, Int), path: List(NodeId)) -> Bool { case path { [] | [_] -> True @@ -40,13 +31,11 @@ fn is_valid_path(graph: Graph(n, Int), path: List(NodeId)) -> Bool { let edge_exists = model.successors(graph, first) |> list.any(fn(pair) { pair.0 == second }) - edge_exists && is_valid_path(graph, [second, ..rest]) } } } -/// Calculate total weight of a path fn calculate_path_weight(graph: Graph(n, Int), path: List(NodeId)) -> Int { case path { [] | [_] -> 0 @@ -62,7 +51,6 @@ fn calculate_path_weight(graph: Graph(n, Int), path: List(NodeId)) -> Int { } } -/// Check if node b is reachable from node a via BFS fn is_reachable(graph: Graph(n, e), from: NodeId, to: NodeId) -> Bool { let visited = traversal.walk(graph, from: from, using: traversal.BreadthFirst) list.contains(visited, to) @@ -72,26 +60,12 @@ fn is_reachable(graph: Graph(n, e), from: NodeId, to: NodeId) -> Bool { // CATEGORY 1: ALGORITHM CROSS-VALIDATION // ============================================================================ -// ---------------------------------------------------------------------------- -// SCC: Tarjan vs Kosaraju - Both should find same strongly connected components -// ---------------------------------------------------------------------------- - pub fn scc_tarjan_equals_kosaraju_test() { - // Example-based test with known SCC structure - // Graph with 2 SCCs: {0, 1} and {2} - let graph = - model.new(model.Directed) - |> model.add_node(0, 0) - |> model.add_node(1, 1) - |> model.add_node(2, 2) - |> model.add_edge(from: 0, to: 1, with: 1) - |> model.add_edge(from: 1, to: 0, with: 1) - |> model.add_edge(from: 1, to: 2, with: 1) + use graph <- qcheck.given(qcheck_generators.directed_graph_generator()) let tarjan = connectivity.strongly_connected_components(graph) let kosaraju = connectivity.kosaraju(graph) - // Convert to sets for comparison (order doesn't matter) let tarjan_sets = tarjan |> list.map(set.from_list) @@ -102,76 +76,64 @@ pub fn scc_tarjan_equals_kosaraju_test() { |> list.map(set.from_list) |> set.from_list - // Both algorithms should find the same components assert tarjan_sets == kosaraju_sets } -// ---------------------------------------------------------------------------- -// MST: Kruskal vs Prim - Total weights should match -// ---------------------------------------------------------------------------- - pub fn mst_kruskal_equals_prim_weight_test() { - // Create small connected undirected graph - let graph = - model.new(model.Undirected) - |> model.add_node(0, 0) - |> model.add_node(1, 1) - |> model.add_node(2, 2) - |> model.add_node(3, 3) - |> model.add_edge(from: 0, to: 1, with: 1) - |> model.add_edge(from: 1, to: 2, with: 2) - |> model.add_edge(from: 2, to: 3, with: 3) - |> model.add_edge(from: 0, to: 3, with: 10) - - let kruskal_edges = mst.kruskal(in: graph, with_compare: int.compare) - let prim_edges = mst.prim(in: graph, with_compare: int.compare) - - let kruskal_weight = - kruskal_edges - |> list.fold(0, fn(sum, edge) { sum + edge.weight }) - - let prim_weight = - prim_edges - |> list.fold(0, fn(sum, edge) { sum + edge.weight }) - - // Both should produce same total weight - assert kruskal_weight == prim_weight + use graph <- qcheck.given(qcheck_generators.undirected_graph_generator()) + + case model.order(graph) { + 0 -> Nil + _ -> { + case list.length(connectivity.strongly_connected_components(graph)) { + 1 -> { + let kruskal_edges = mst.kruskal(in: graph, with_compare: int.compare) + let prim_edges = mst.prim(in: graph, with_compare: int.compare) + + let kruskal_weight = + kruskal_edges + |> list.fold(0, fn(sum, edge) { sum + edge.weight }) + + let prim_weight = + prim_edges + |> list.fold(0, fn(sum, edge) { sum + edge.weight }) + + assert kruskal_weight == prim_weight + } + _ -> Nil + } + } + } } -// ---------------------------------------------------------------------------- -// Pathfinding: Bellman-Ford vs Dijkstra on non-negative graphs -// ---------------------------------------------------------------------------- - pub fn bellman_ford_equals_dijkstra_test() { - // Small graph with non-negative weights - let graph = - model.new(model.Directed) - |> model.add_node(0, 0) - |> model.add_node(1, 1) - |> model.add_node(2, 2) - |> model.add_edge(from: 0, to: 1, with: 5) - |> model.add_edge(from: 1, to: 2, with: 3) - |> model.add_edge(from: 0, to: 2, with: 10) - - let dijkstra_result = dijkstra.shortest_path_int(in: graph, from: 0, to: 2) - - let bellman_result = - bellman_ford.bellman_ford( - in: graph, - from: 0, - to: 2, - with_zero: 0, - with_add: int.add, - with_compare: int.compare, - ) - - // Both should find same path weight - case dijkstra_result, bellman_result { - Some(d_path), bellman_ford.ShortestPath(path: b_path) -> { - assert d_path.total_weight == b_path.total_weight + use #(graph, #(src, dst, _w)) <- qcheck.given( + qcheck_generators.graph_and_edge_generator(model.Directed), + ) + + case model.order(graph) { + 0 -> Nil + _ -> { + let dijkstra_result = + dijkstra.shortest_path_int(in: graph, from: src, to: dst) + let bellman_result = + bellman_ford.bellman_ford( + in: graph, + from: src, + to: dst, + with_zero: 0, + with_add: int.add, + with_compare: int.compare, + ) + + case dijkstra_result, bellman_result { + Some(d_path), bellman_ford.ShortestPath(path: b_path) -> { + assert d_path.total_weight == b_path.total_weight + } + None, bellman_ford.NoPath -> Nil + _, _ -> panic as "Dijkstra and Bellman-Ford disagree on path existence!" + } } - None, bellman_ford.NoPath -> Nil - _, _ -> panic as "Dijkstra and Bellman-Ford disagree on path existence!" } } @@ -179,115 +141,94 @@ pub fn bellman_ford_equals_dijkstra_test() { // CATEGORY 2: PATHFINDING CORRECTNESS // ============================================================================ -// ---------------------------------------------------------------------------- -// Property: Dijkstra path is valid and connects start to goal -// ---------------------------------------------------------------------------- - pub fn dijkstra_path_validity_test() { - let graph = - model.new(model.Directed) - |> model.add_node(0, 0) - |> model.add_node(1, 1) - |> model.add_node(2, 2) - |> model.add_node(3, 3) - |> model.add_edge(from: 0, to: 1, with: 1) - |> model.add_edge(from: 1, to: 2, with: 2) - |> model.add_edge(from: 2, to: 3, with: 3) - - case dijkstra.shortest_path_int(in: graph, from: 0, to: 3) { - Some(path) -> { - // Path should start at start node - assert list.first(path.nodes) == Ok(0) - - // Path should end at goal node - let last = list.last(path.nodes) - assert last == Ok(3) - - // All edges in path should exist - assert is_valid_path(graph, path.nodes) - - // Weight should match actual path weight - let calculated = calculate_path_weight(graph, path.nodes) - assert path.total_weight == calculated + use #(graph, #(src, dst, _w)) <- qcheck.given( + qcheck_generators.graph_and_edge_generator(model.Directed), + ) + + case model.order(graph) { + 0 -> Nil + _ -> { + case dijkstra.shortest_path_int(in: graph, from: src, to: dst) { + Some(path) -> { + assert list.first(path.nodes) == Ok(src) + assert list.last(path.nodes) == Ok(dst) + assert is_valid_path(graph, path.nodes) + + let calculated = calculate_path_weight(graph, path.nodes) + assert path.total_weight == calculated + } + None -> Nil + } } - None -> panic as "Path should exist!" } } -// ---------------------------------------------------------------------------- -// Property: No path should return None and BFS should confirm -// ---------------------------------------------------------------------------- - pub fn dijkstra_no_path_confirmed_by_bfs_test() { - // Disconnected graph - let graph = - model.new(model.Directed) - |> model.add_node(0, 0) - |> model.add_node(1, 1) - |> model.add_node(2, 2) - |> model.add_edge(from: 0, to: 1, with: 5) - // Node 2 is unreachable from 0 - - case dijkstra.shortest_path_int(in: graph, from: 0, to: 2) { - None -> { - // BFS should also confirm no path - assert !is_reachable(graph, 0, 2) + use #(graph, #(src, dst, _w)) <- qcheck.given( + qcheck_generators.graph_and_edge_generator(model.Directed), + ) + + case model.order(graph) { + 0 -> Nil + _ -> { + case dijkstra.shortest_path_int(in: graph, from: src, to: dst) { + None -> { + assert !is_reachable(graph, src, dst) + } + Some(_) -> Nil + } } - Some(_) -> panic as "Should not find path to unreachable node!" } } -// ---------------------------------------------------------------------------- -// Property: Undirected paths are symmetric -// ---------------------------------------------------------------------------- - pub fn undirected_paths_symmetric_test() { - let graph = - model.new(model.Undirected) - |> model.add_node(0, 0) - |> model.add_node(1, 1) - |> model.add_node(2, 2) - |> model.add_edge(from: 0, to: 1, with: 3) - |> model.add_edge(from: 1, to: 2, with: 4) - - let forward = dijkstra.shortest_path_int(in: graph, from: 0, to: 2) - let backward = dijkstra.shortest_path_int(in: graph, from: 2, to: 0) - - case forward, backward { - Some(f_path), Some(b_path) -> { - // Weights should be equal for undirected graph - assert f_path.total_weight == b_path.total_weight + use #(graph, #(src, dst, _w)) <- qcheck.given( + qcheck_generators.graph_and_edge_generator(model.Undirected), + ) + + case model.order(graph) { + 0 -> Nil + _ -> { + let forward = dijkstra.shortest_path_int(in: graph, from: src, to: dst) + let backward = dijkstra.shortest_path_int(in: graph, from: dst, to: src) + + case forward, backward { + Some(f_path), Some(b_path) -> { + assert f_path.total_weight == b_path.total_weight + } + None, None -> Nil + _, _ -> panic as "Symmetric paths should both exist or both not exist!" + } } - None, None -> Nil - _, _ -> panic as "Symmetric paths should both exist or both not exist!" } } -// ---------------------------------------------------------------------------- -// Property: Path from A to C via B >= direct path from A to C (triangle inequality) -// ---------------------------------------------------------------------------- - pub fn triangle_inequality_test() { - let graph = - model.new(model.Directed) - |> model.add_node(0, 0) - |> model.add_node(1, 1) - |> model.add_node(2, 2) - |> model.add_edge(from: 0, to: 1, with: 5) - |> model.add_edge(from: 1, to: 2, with: 3) - |> model.add_edge(from: 0, to: 2, with: 10) - - let direct = dijkstra.shortest_path_int(in: graph, from: 0, to: 2) - let via_1_part1 = dijkstra.shortest_path_int(in: graph, from: 0, to: 1) - let via_1_part2 = dijkstra.shortest_path_int(in: graph, from: 1, to: 2) - - case direct, via_1_part1, via_1_part2 { - Some(d), Some(p1), Some(p2) -> { - let via_weight = p1.total_weight + p2.total_weight - // Direct path should be <= path via intermediate node - assert d.total_weight <= via_weight + use #(graph, #(src, dst, _w)) <- qcheck.given( + qcheck_generators.graph_and_edge_generator(model.Directed), + ) + + case model.order(graph) { + 0 -> Nil + _ -> { + let n = model.order(graph) + let via_node = { src + dst } % n + + let direct = dijkstra.shortest_path_int(in: graph, from: src, to: dst) + let via_1_part1 = + dijkstra.shortest_path_int(in: graph, from: src, to: via_node) + let via_1_part2 = + dijkstra.shortest_path_int(in: graph, from: via_node, to: dst) + + case direct, via_1_part1, via_1_part2 { + Some(d), Some(p1), Some(p2) -> { + let via_weight = p1.total_weight + p2.total_weight + assert d.total_weight <= via_weight + } + _, _, _ -> Nil + } } - _, _, _ -> Nil } } @@ -295,25 +236,11 @@ pub fn triangle_inequality_test() { // CATEGORY 3: COMPLEX INVARIANTS // ============================================================================ -// ---------------------------------------------------------------------------- -// Property: SCC components partition the graph -// ---------------------------------------------------------------------------- - pub fn scc_components_partition_graph_test() { - let graph = - model.new(model.Directed) - |> model.add_node(0, 0) - |> model.add_node(1, 1) - |> model.add_node(2, 2) - |> model.add_node(3, 3) - |> model.add_edge(from: 0, to: 1, with: 1) - |> model.add_edge(from: 1, to: 0, with: 1) - |> model.add_edge(from: 2, to: 3, with: 1) - |> model.add_edge(from: 3, to: 2, with: 1) + use graph <- qcheck.given(qcheck_generators.directed_graph_generator()) let components = connectivity.strongly_connected_components(graph) - // Every node should be in exactly one component let all_in_components = components |> list.flat_map(fn(comp) { comp }) @@ -323,10 +250,8 @@ pub fn scc_components_partition_graph_test() { model.all_nodes(graph) |> set.from_list - // Coverage: all nodes appear in some component assert all_in_components == all_graph_nodes - // Disjointness: components don't overlap let pairs = list.combination_pairs(components) let are_disjoint = @@ -340,126 +265,91 @@ pub fn scc_components_partition_graph_test() { assert are_disjoint } -// ---------------------------------------------------------------------------- -// Property: MST is actually a spanning tree (V-1 edges, connected, acyclic) -// ---------------------------------------------------------------------------- - -pub fn mst_is_spanning_tree_test() { - let graph = - model.new(model.Undirected) - |> model.add_node(0, 0) - |> model.add_node(1, 1) - |> model.add_node(2, 2) - |> model.add_node(3, 3) - |> model.add_edge(from: 0, to: 1, with: 1) - |> model.add_edge(from: 1, to: 2, with: 2) - |> model.add_edge(from: 2, to: 3, with: 3) - |> model.add_edge(from: 0, to: 3, with: 10) - - let mst_edges = mst.kruskal(in: graph, with_compare: int.compare) - let n = model.order(graph) - - // Property 1: Tree has V-1 edges - assert list.length(mst_edges) == n - 1 - - // Property 2: Spans all nodes - let nodes_in_mst = - mst_edges - |> list.flat_map(fn(edge) { [edge.from, edge.to] }) - |> set.from_list - - assert set.size(nodes_in_mst) == n +pub fn mst_is_spanning_forest_test() { + use graph <- qcheck.given(qcheck_generators.undirected_graph_generator()) - // Property 3: Is connected (can reach all nodes from any node) - let mst_graph = model.new(model.Undirected) + case model.order(graph) { + 0 -> Nil + _ -> { + let mst_edges = mst.kruskal(in: graph, with_compare: int.compare) + let num_components = + list.length(connectivity.strongly_connected_components(graph)) + let n = model.order(graph) - let mst_graph = - list.range(0, n - 1) - |> list.fold(mst_graph, fn(g, i) { model.add_node(g, i, i) }) - - let mst_graph = - list.fold(mst_edges, mst_graph, fn(g, edge) { - model.add_edge(g, from: edge.from, to: edge.to, with: edge.weight) - }) - - let reachable = - traversal.walk(mst_graph, from: 0, using: traversal.BreadthFirst) - - assert list.length(reachable) == n + // Forest has V-C edges + assert list.length(mst_edges) == n - num_components + } + } } -// ---------------------------------------------------------------------------- -// Property: Bridges removal increases connected components -// ---------------------------------------------------------------------------- - pub fn bridges_increase_components_test() { - // Graph: 0-1-2 where 1-2 is a bridge - let graph = - model.new(model.Undirected) - |> model.add_node(0, 0) - |> model.add_node(1, 1) - |> model.add_node(2, 2) - |> model.add_edge(from: 0, to: 1, with: 1) - |> model.add_edge(from: 1, to: 0, with: 1) - // Self-loop for testing - |> model.add_edge(from: 1, to: 2, with: 1) - - let result = connectivity.analyze(in: graph) - - // Should find the bridge - assert result.bridges != [] - - // Removing a bridge should disconnect the graph - let bridge = case list.first(result.bridges) { - Ok(b) -> b - Error(_) -> panic as "Should have found bridges" + use #(graph, #(src, dst, weight)) <- qcheck.given( + qcheck_generators.graph_and_edge_generator(model.Undirected), + ) + + case model.order(graph) { + 0 | 1 -> Nil + _ -> { + // Force edge insertion so we might create a bridge + let graph = model.add_edge(graph, from: src, to: dst, with: weight) + + let result = connectivity.analyze(in: graph) + + let base_comp = + list.length(connectivity.strongly_connected_components(graph)) + + case result.bridges { + [] -> Nil + [bridge, ..] -> { + let #(b_src, b_dst) = bridge + + let without_bridge = + graph + |> model.remove_edge(b_src, b_dst) + |> model.remove_edge(b_dst, b_src) + + let split_comp = + list.length(connectivity.strongly_connected_components( + without_bridge, + )) + assert split_comp > base_comp + } + } + } } - - let #(src, dst) = bridge - let without_bridge = - graph - |> model.remove_edge(src, dst) - |> model.remove_edge(dst, src) - - // After removing bridge, node 2 should be unreachable from 0 - assert !is_reachable(without_bridge, 0, 2) } -// ---------------------------------------------------------------------------- -// Property: Degree centrality matches manual count -// ---------------------------------------------------------------------------- - pub fn degree_centrality_correctness_test() { - let graph = - model.new(model.Directed) - |> model.add_node(0, 0) - |> model.add_node(1, 1) - |> model.add_node(2, 2) - |> model.add_edge(from: 0, to: 1, with: 1) - |> model.add_edge(from: 0, to: 2, with: 1) - |> model.add_edge(from: 1, to: 2, with: 1) + use graph <- qcheck.given(qcheck_generators.directed_graph_generator()) let out_degrees = centrality.degree(graph, centrality.OutDegree) - // Node 0 has 2 outgoing edges (normalized: 2/2 = 1.0) - let degree_0 = case dict.get(out_degrees, 0) { - Ok(degree) -> degree - Error(_) -> panic as "Should have degree for node 0" - } - // Centrality is normalized, so check it's positive - assert degree_0 >. 0.0 + case model.order(graph) { + 0 | 1 -> Nil + _ -> { + let max_possible = model.order(graph) - 1 - // Node 1 has 1 outgoing edge (normalized: 1/2 = 0.5) - let degree_1 = case dict.get(out_degrees, 1) { - Ok(degree) -> degree - Error(_) -> panic as "Should have degree for node 1" - } - assert degree_1 >. 0.0 + // Centrality score must be between 0.0 and infinity (normalized against order-1, could be >1.0 with loops/parallel edges) + let valid_range = + dict.values(out_degrees) + |> list.all(fn(score) { score >=. 0.0 }) - // Node 2 has 0 outgoing edges - let degree_2 = case dict.get(out_degrees, 2) { - Ok(degree) -> degree - Error(_) -> panic as "Should have degree for node 2" + assert valid_range + + // Compare scores + let all_match = + list.all(model.all_nodes(graph), fn(node) { + let expected_degree = list.length(model.successors(graph, node)) + let expected_score = + int.to_float(expected_degree) /. int.to_float(max_possible) + + case dict.get(out_degrees, node) { + Ok(score) -> score == expected_score + Error(_) -> False + } + }) + + assert all_match + } } - assert degree_2 == 0.0 } diff --git a/test/yog/property_tests.gleam b/test/yog/property_tests.gleam index 6ebbb2e..db66462 100644 --- a/test/yog/property_tests.gleam +++ b/test/yog/property_tests.gleam @@ -11,7 +11,8 @@ import gleam/list import gleam/set import gleeunit import qcheck -import yog/model.{type Graph, type GraphType, type NodeId} +import yog/model.{type Graph, type NodeId} +import yog/qcheck_generators import yog/transform import yog/traversal @@ -23,91 +24,16 @@ pub fn main() { // GENERATORS // ============================================================================ -/// Generate a random GraphType (Directed or Undirected) -fn graph_type_generator() { - use is_directed <- qcheck.map(qcheck.bool()) - case is_directed { - True -> model.Directed - False -> model.Undirected - } -} - -/// Generate a random graph with Int node data and Int edge weights -/// - Nodes: 0 to max_nodes-1 -/// - Edges: Random connections with positive weights fn graph_generator() { - use kind <- qcheck.bind(graph_type_generator()) - use num_nodes <- qcheck.bind(qcheck.bounded_int(0, 15)) - use num_edges <- qcheck.bind(qcheck.bounded_int(0, 30)) - - graph_generator_custom(kind, num_nodes, num_edges) -} - -/// Generate a graph with specific parameters -fn graph_generator_custom( - kind: GraphType, - num_nodes: Int, - num_edges: Int, -) -> qcheck.Generator(Graph(Int, Int)) { - use edges <- qcheck.map(qcheck.fixed_length_list_from( - edge_triple_generator(num_nodes), - num_edges, - )) - - // Build graph: add nodes first, then edges - let graph = model.new(kind) - - let graph = case num_nodes { - 0 -> graph - _ -> { - list.range(0, num_nodes - 1) - |> list.fold(graph, fn(g, node_id) { model.add_node(g, node_id, node_id) }) - } - } - - edges - |> list.fold(graph, fn(g, edge) { - let #(src, dst, weight) = edge - model.add_edge(g, from: src, to: dst, with: weight) - }) + qcheck_generators.graph_generator() } -/// Generate an undirected graph fn undirected_graph_generator() { - use num_nodes <- qcheck.bind(qcheck.bounded_int(0, 15)) - use num_edges <- qcheck.bind(qcheck.bounded_int(0, 30)) - - graph_generator_custom(model.Undirected, num_nodes, num_edges) + qcheck_generators.undirected_graph_generator() } -/// Generate a directed graph fn directed_graph_generator() { - use num_nodes <- qcheck.bind(qcheck.bounded_int(0, 15)) - use num_edges <- qcheck.bind(qcheck.bounded_int(0, 30)) - - graph_generator_custom(model.Directed, num_nodes, num_edges) -} - -/// Generate an edge triple #(src, dst, weight) -fn edge_triple_generator(max_node_id: Int) { - case max_node_id { - 0 -> qcheck.return(#(0, 0, 1)) - _ -> { - use src <- qcheck.bind(qcheck.bounded_int(0, max_node_id - 1)) - use dst <- qcheck.bind(qcheck.bounded_int(0, max_node_id - 1)) - use weight <- qcheck.map(qcheck.bounded_int(1, 100)) - #(src, dst, weight) - } - } -} - -/// Generate a traversal order (BFS or DFS) -fn traversal_order_generator() { - use is_bfs <- qcheck.map(qcheck.bool()) - case is_bfs { - True -> traversal.BreadthFirst - False -> traversal.DepthFirst - } + qcheck_generators.directed_graph_generator() } // ============================================================================ @@ -122,14 +48,14 @@ fn graphs_equal(g1: Graph(n, e), g2: Graph(n, e)) -> Bool { && g1.in_edges == g2.in_edges } -/// Get all edges from a graph as a list of tuples -fn get_all_edges(graph: Graph(n, e)) -> List(#(NodeId, NodeId, e)) { - dict.fold(graph.out_edges, [], fn(acc, src, targets) { - dict.fold(targets, acc, fn(edge_acc, dst, weight) { - [#(src, dst, weight), ..edge_acc] - }) - }) -} +// Get all edges from a graph as a list of tuples +// fn get_all_edges(graph: Graph(n, e)) -> List(#(NodeId, NodeId, e)) { +// dict.fold(graph.out_edges, [], fn(acc, src, targets) { +// dict.fold(targets, acc, fn(edge_acc, dst, weight) { +// [#(src, dst, weight), ..edge_acc] +// }) +// }) +// } /// Count edges manually by iterating through out_edges fn count_edges_manual(graph: Graph(n, e)) -> Int { @@ -206,53 +132,61 @@ pub fn undirected_symmetry_test() { // PROPERTY 4: Add/Remove Edge are Inverses // ============================================================================ // Adding and then removing an edge should make it disappear -// Using example-based tests instead of full PBT for performance +// Using full PBT. pub fn add_remove_edge_inverse_directed_test() { - let graph = - model.new(model.Directed) - |> model.add_node(0, 0) - |> model.add_node(1, 1) + use #(graph, #(src, dst, weight)) <- qcheck.given( + qcheck_generators.graph_and_edge_generator(model.Directed), + ) - let with_edge = model.add_edge(graph, from: 0, to: 1, with: 10) + case model.order(graph) { + 0 -> Nil + _ -> { + let with_edge = model.add_edge(graph, from: src, to: dst, with: weight) - let edge_exists = - model.successors(with_edge, 0) - |> list.any(fn(pair) { pair.0 == 1 }) - assert edge_exists + let edge_exists = + model.successors(with_edge, src) + |> list.any(fn(pair) { pair.0 == dst }) + assert edge_exists - let removed = model.remove_edge(with_edge, 0, 1) + let removed = model.remove_edge(with_edge, src, dst) - let edge_gone = - model.successors(removed, 0) - |> list.all(fn(pair) { pair.0 != 1 }) - assert edge_gone + let edge_gone = + model.successors(removed, src) + |> list.all(fn(pair) { pair.0 != dst }) + assert edge_gone + } + } } pub fn add_remove_edge_inverse_undirected_test() { - let graph = - model.new(model.Undirected) - |> model.add_node(0, 0) - |> model.add_node(1, 1) - - let with_edge = model.add_edge(graph, from: 0, to: 1, with: 10) - - // Both directions should exist - let forward_exists = - model.successors(with_edge, 0) - |> list.any(fn(pair) { pair.0 == 1 }) - let backward_exists = - model.successors(with_edge, 1) - |> list.any(fn(pair) { pair.0 == 0 }) - assert forward_exists && backward_exists - - // Remove one direction - let removed = model.remove_edge(with_edge, 0, 1) - - let forward_gone = - model.successors(removed, 0) - |> list.all(fn(pair) { pair.0 != 1 }) - assert forward_gone + use #(graph, #(src, dst, weight)) <- qcheck.given( + qcheck_generators.graph_and_edge_generator(model.Undirected), + ) + + case model.order(graph) { + 0 -> Nil + _ -> { + let with_edge = model.add_edge(graph, from: src, to: dst, with: weight) + + // Both directions should exist + let forward_exists = + model.successors(with_edge, src) + |> list.any(fn(pair) { pair.0 == dst }) + let backward_exists = + model.successors(with_edge, dst) + |> list.any(fn(pair) { pair.0 == src }) + assert forward_exists && backward_exists + + // Remove one direction + let removed = model.remove_edge(with_edge, src, dst) + + let forward_gone = + model.successors(removed, src) + |> list.all(fn(pair) { pair.0 != dst }) + assert forward_gone + } + } } // ============================================================================ @@ -421,43 +355,38 @@ pub fn to_undirected_creates_symmetry_test() { // PROPERTY 10: BFS/DFS Visit Each Node at Most Once // ============================================================================ // Traversal should never visit the same node twice -// Using example-based tests for specific graph structures +// Using full PBT. pub fn traversal_no_duplicates_bfs_test() { - // Simple linear graph: 0 -> 1 -> 2 - let graph = - model.new(model.Directed) - |> model.add_node(0, 0) - |> model.add_node(1, 1) - |> model.add_node(2, 2) - |> model.add_edge(from: 0, to: 1, with: 1) - |> model.add_edge(from: 1, to: 2, with: 1) + use graph <- qcheck.given(qcheck_generators.graph_generator()) - let visited = traversal.walk(graph, from: 0, using: traversal.BreadthFirst) + case model.order(graph) { + 0 -> Nil + _ -> { + let visited = + traversal.walk(graph, from: 0, using: traversal.BreadthFirst) - let unique_count = set.size(set.from_list(visited)) - let total_count = list.length(visited) + let unique_count = set.size(set.from_list(visited)) + let total_count = list.length(visited) - assert unique_count == total_count + assert unique_count == total_count + } + } } pub fn traversal_no_duplicates_dfs_test() { - // Graph with cycle: 0 -> 1 -> 2 -> 0 - let graph = - model.new(model.Directed) - |> model.add_node(0, 0) - |> model.add_node(1, 1) - |> model.add_node(2, 2) - |> model.add_edge(from: 0, to: 1, with: 1) - |> model.add_edge(from: 1, to: 2, with: 1) - |> model.add_edge(from: 2, to: 0, with: 1) - - let visited = traversal.walk(graph, from: 0, using: traversal.DepthFirst) - - let unique_count = set.size(set.from_list(visited)) - let total_count = list.length(visited) - - // Should visit each node exactly once despite cycle - assert unique_count == total_count - assert total_count == 3 + use graph <- qcheck.given(qcheck_generators.graph_generator()) + + case model.order(graph) { + 0 -> Nil + _ -> { + let visited = traversal.walk(graph, from: 0, using: traversal.DepthFirst) + + let unique_count = set.size(set.from_list(visited)) + let total_count = list.length(visited) + + // Should visit each node exactly once despite cycles + assert unique_count == total_count + } + } } diff --git a/test/yog/qcheck_generators.gleam b/test/yog/qcheck_generators.gleam new file mode 100644 index 0000000..bb1a5e5 --- /dev/null +++ b/test/yog/qcheck_generators.gleam @@ -0,0 +1,104 @@ +import gleam/list +import qcheck +import yog/model.{type Graph, type GraphType} +import yog/traversal + +/// Generate a random GraphType (Directed or Undirected) +pub fn graph_type_generator() { + use is_directed <- qcheck.map(qcheck.bool()) + case is_directed { + True -> model.Directed + False -> model.Undirected + } +} + +/// Generate a random graph with Int node data and Int edge weights +/// - Nodes: 0 to max_nodes-1 +/// - Edges: Random connections with positive weights +pub fn graph_generator() { + use kind <- qcheck.bind(graph_type_generator()) + use num_nodes <- qcheck.bind(qcheck.bounded_int(0, 15)) + use num_edges <- qcheck.bind(qcheck.bounded_int(0, 30)) + + graph_generator_custom(kind, num_nodes, num_edges) +} + +fn build_nodes(graph: Graph(Int, e), current: Int, max: Int) -> Graph(Int, e) { + case current > max { + True -> graph + False -> + build_nodes(model.add_node(graph, current, current), current + 1, max) + } +} + +/// Generate a graph with specific parameters +pub fn graph_generator_custom( + kind: GraphType, + num_nodes: Int, + num_edges: Int, +) -> qcheck.Generator(Graph(Int, Int)) { + use edges <- qcheck.map(qcheck.fixed_length_list_from( + edge_triple_generator(num_nodes), + num_edges, + )) + + // Build graph: add nodes first, then edges + let graph = build_nodes(model.new(kind), 0, num_nodes - 1) + + let valid_edges = case num_nodes { + 0 -> [] + _ -> edges + } + + valid_edges + |> list.fold(graph, fn(g, edge) { + let #(src, dst, weight) = edge + model.add_edge(g, from: src, to: dst, with: weight) + }) +} + +/// Generate an undirected graph +pub fn undirected_graph_generator() { + use num_nodes <- qcheck.bind(qcheck.bounded_int(0, 15)) + use num_edges <- qcheck.bind(qcheck.bounded_int(0, 30)) + + graph_generator_custom(model.Undirected, num_nodes, num_edges) +} + +/// Generate a directed graph +pub fn directed_graph_generator() { + use num_nodes <- qcheck.bind(qcheck.bounded_int(0, 15)) + use num_edges <- qcheck.bind(qcheck.bounded_int(0, 30)) + + graph_generator_custom(model.Directed, num_nodes, num_edges) +} + +/// Generate an edge triple #(src, dst, weight) +pub fn edge_triple_generator(num_nodes: Int) { + case num_nodes { + 0 -> qcheck.return(#(0, 0, 1)) + _ -> { + use src <- qcheck.bind(qcheck.bounded_int(0, num_nodes - 1)) + use dst <- qcheck.bind(qcheck.bounded_int(0, num_nodes - 1)) + use weight <- qcheck.map(qcheck.bounded_int(1, 100)) + #(src, dst, weight) + } + } +} + +/// Generate a traversal order (BFS or DFS) +pub fn traversal_order_generator() { + use is_bfs <- qcheck.map(qcheck.bool()) + case is_bfs { + True -> traversal.BreadthFirst + False -> traversal.DepthFirst + } +} + +pub fn graph_and_edge_generator(kind: GraphType) { + use num_nodes <- qcheck.bind(qcheck.bounded_int(1, 15)) + use num_edges <- qcheck.bind(qcheck.bounded_int(0, 30)) + use graph <- qcheck.bind(graph_generator_custom(kind, num_nodes, num_edges)) + use edge <- qcheck.map(edge_triple_generator(num_nodes)) + #(graph, edge) +} From 60001433c19b2f678aca556fe7798262ac4bc649 Mon Sep 17 00:00:00 2001 From: Mafinar Khan Date: Sat, 14 Mar 2026 14:19:38 -0400 Subject: [PATCH 6/6] chore: mention property testing in README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b28db6b..d5dca64 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ In the world of computer science, this is the literal definition of Graph Theory - **Minimum Cost Flow (MCF)**: Global optimization using the robust **Network Simplex** algorithm - **Disjoint Set (Union-Find)**: With path compression and union by rank - **Efficient Data Structures**: Pairing heap for priority queues, two-list queue for BFS +- **Property-Based Testing**: Exhaustively tested across core graph operations and invariants using `qcheck` ## Installation