Purpose: Verify YogEx algorithm outputs against NetworkX — the de-facto reference implementation for graph algorithms in Python — using property-based testing.
Overview
The oracle suite treats NetworkX as a ground-truth oracle. For every property test, a random graph is generated, fed to both YogEx and NetworkX, and the results are compared. If YogEx disagrees with NetworkX, the property fails and StreamData prints a deterministic seed so the failure is reproducible.
This is not a replacement for unit tests (those live in test/yog/) — it is a cross-implementation parity layer that catches semantic drift, edge-case mishandling, and subtle bugs that unit tests alone rarely reach.
Methodology
Harness Architecture
┌─────────────────┐ JSON payload ┌──────────────────────┐
│ Elixir test │ ─────────────────────>│ Python dispatcher │
│ (ExUnit + │ (graph + algorithm │ (run_algorithm.py) │
│ StreamData) │ + options) │ │
│ │ │ ┌───────────────┐ │
│ YogEx result │ <─────────────────────│ │ NetworkX │ │
│ vs │ JSON result │ │ adapter │ │
│ NetworkX result│ │ └───────────────┘ │
└─────────────────┘ └──────────────────────┘- Graph marshalling —
Yog.Oracle.NetworkX.encode_graph/1serialises aYog.Graphto JSON. Atoms are converted to strings, and other JSON-compatible types (integers, strings) are passed through as-is. - Subprocess dispatch —
System.cmd/3spawnspython3 run_algorithm.py <tmpfile>. Each call is isolated, debuggable, and avoids NIF complexity. - Result decoding —
decode_result/2reverses the encoding and normalises shapes (e.g."__Inf__"→:infinity, nested dicts →%{node_id => value}). - Property assertion — ExUnit compares the decoded NetworkX result against the YogEx result.
Adapter Health Check
Before any oracle property runs, the harness executes 10 round-trip self-tests (NetworkX.adapter_health/0):
| # | Test | Validates |
|---|---|---|
| 1 | Empty graph | Zero nodes, zero edges |
| 2 | Single node, no edges | Node presence without edges |
| 3 | Single directed edge | Directed edge round-trip |
| 4 | Self-loop (directed) | Self-loop encoding |
| 5 | Self-loop (undirected) | Undirected self-loop encoding |
| 6 | Disconnected components | Multiple components survive JSON |
| 7 | Atom node IDs | :foo → "foo" (converted to string) |
| 8 | String node IDs | "alice" round-trip |
| 9 | Integer node IDs | 42 round-trip |
| 10 | Weighted floats (negative) | -3.5 round-trip |
If any self-test fails, the entire oracle suite is skipped with a clear reason — never a silent pass.
NetworkX vs YogEx Parity Matrix
| Category | Algorithm | YogEx Module | NetworkX Equivalent | Parity Type | Properties | Status |
|---|---|---|---|---|---|---|
| Pathfinding | Dijkstra SSSP | Yog.Pathfinding.Dijkstra | nx.single_source_dijkstra_path_length | Exact distance maps | 2 | ✅ |
| A* shortest path | Yog.Pathfinding.AStar | nx.astar_path | Exact path length | 1 | ✅ | |
| Bellman-Ford | Yog.Pathfinding.BellmanFord | nx.bellman_ford_path_length | Exact path + error cases | 1 | ✅ | |
| Floyd-Warshall | Yog.Pathfinding.FloydWarshall | nx.floyd_warshall | Exact all-pairs distances | 1 | ✅ | |
| Johnson's | Yog.Pathfinding.Johnson | nx.johnson | Exact all-pairs distances | 1 | ✅ | |
| Bidirectional Dijkstra | Yog.Pathfinding.Bidirectional | nx.bidirectional_dijkstra | Exact path + length | 1 | ✅ | |
| Bidirectional BFS | Yog.Pathfinding.Bidirectional | nx.bidirectional_shortest_path | Exact unweighted path | 1 | ✅ | |
| Yen k-shortest | Yog.Pathfinding.Yen | nx.shortest_simple_paths | Exact k paths | 0 | ⏸️ | |
| Flow & Cuts | Edmonds-Karp max flow | Yog.Flow.MaxFlow | nx.maximum_flow_value (EK) | Exact flow value | 1 | ✅ |
| Dinic max flow | Yog.Flow.MaxFlow | nx.maximum_flow_value (Dinic) | Exact flow value | 1 | ✅ | |
| Stoer-Wagner min cut | Yog.Flow.MinCut | nx.stoer_wagner | Exact cut value | 1 | ✅ | |
| Spanning Tree | Kruskal MST | Yog.MST | nx.minimum_spanning_tree (Kruskal) | Exact total weight | 1 | ✅ |
| Prim MST | Yog.MST | nx.minimum_spanning_tree (Prim) | Exact total weight | 1 | ✅ | |
| Borůvka MST | Yog.MST | nx.minimum_spanning_tree (Borůvka) | Exact total weight | 1 | ✅ | |
| Maximum ST (Kruskal) | Yog.MST | nx.maximum_spanning_tree | Exact total weight | 1 | ✅ | |
| Min Arborescence | Yog.MST | nx.minimum_spanning_arborescence | Exact total weight | 0 | 🔴 | |
| Matching | Hopcroft-Karp | Yog.Matching | nx.bipartite.maximum_matching | Exact cardinality | 1 | ✅ |
| Blossom (general) | Yog.Matching | nx.max_weight_matching | Exact cardinality | 1 | ✅ | |
| Hungarian (min) | Yog.Matching | nx.bipartite.minimum_weight_full_matching | Exact optimal weight | 1 | ✅ | |
| Hungarian (max) | Yog.Matching | nx.bipartite.minimum_weight_full_matching (negated) | Exact optimal weight | 1 | ✅ | |
| Connectivity | SCC (Tarjan) | Yog.Connectivity.SCC | nx.strongly_connected_components | Exact component sets | 1 | ✅ |
| Connected Components | Yog.Connectivity | nx.connected_components | Exact component sets | 1 | ✅ | |
| Weakly CC | Yog.Connectivity.Components | nx.weakly_connected_components | Exact component sets | 1 | ✅ | |
| Properties | Bipartite check | Yog.Property.Bipartite | nx.is_bipartite | Exact boolean | 1 | ✅ |
| Tree check | Yog.Property.Structure | nx.is_tree | Exact boolean | 1 | ✅ | |
| Forest check | Yog.Property.Structure | nx.is_forest | Exact boolean | 1 | ✅ | |
| DAG check | Yog.Property.Cyclicity | nx.is_directed_acyclic_graph | Exact boolean | 1 | ✅ | |
| Clique number | Yog.Property.Clique | nx.find_cliques + max size | Exact size | 1 | ✅ | |
| Isomorphism | Yog.Operation | nx.is_isomorphic | Exact boolean | 1 | ✅ | |
| WL Graph Hash | Yog.Property.WeisfeilerLehman | Local isomorphism parity | Hash equality | 1 | ✅ | |
| Traversal | BFS layers | Yog.Traversal | nx.bfs_layers | Exact level sets | 1 | ✅ |
| Lexicographic topo-sort | Yog.Traversal.Sort | nx.lexicographical_topological_sort | Exact ordering | 1 | ✅ | |
| Topological generations | Yog.DAG.Algorithm | nx.topological_generations | Exact generation lists | 1 | ✅ | |
| Centrality | Degree centrality | Yog.Centrality | nx.degree_centrality | Exact values | 1 | ✅ |
| In-degree centrality | Yog.Centrality | nx.in_degree_centrality | Exact values | 1 | ✅ | |
| Out-degree centrality | Yog.Centrality | nx.out_degree_centrality | Exact values | 1 | ✅ | |
| Closeness centrality | Yog.Centrality | nx.closeness_centrality | Exact values | 1 | ✅ | |
| Harmonic centrality | Yog.Centrality | nx.harmonic_centrality | Exact values | 1 | ✅ | |
| Betweenness centrality | Yog.Centrality | nx.betweenness_centrality | Exact values | 2 | ✅ | |
| PageRank | Yog.Centrality | nx.pagerank | Tolerance-based | 1 | ✅ | |
| HITS | Yog.Centrality | nx.hits | Tolerance-based | 0 | 🔴 | |
| Katz centrality | Yog.Centrality | nx.katz_centrality | Tolerance-based | 1 | ✅ | |
| Eigenvector centrality | Yog.Centrality | nx.eigenvector_centrality | Tolerance-based | 1 | ✅ | |
| Community | Louvain | Yog.Community.Louvain | nx.community.louvain_communities | NMI ≥ 0.85 | 1 | ✅ |
| Leiden | Yog.Community.Leiden | nx.community.louvain_communities* | NMI ≥ 0.90 | 1 | ✅ | |
| Label Propagation | Yog.Community.LabelPropagation | nx.community.label_propagation_communities | NMI ≥ 0.70 | 1 | ✅ |
* Leiden uses Louvain as the NetworkX proxy because NetworkX does not ship a native Leiden implementation.
Legend
| Symbol | Meaning |
|---|---|
| ✅ | Committed to suite, passing |
| ⏸️ | Deferred — known convention or stability gaps (see Centrality deferral) |
| 🔴 | Documented divergence — comparison is invalid by design (e.g. Min Arborescence: NetworkX picks its own root, so total-weight parity is meaningless) |
Outcome
Current Suite Statistics
| Metric | Value |
|---|---|
| Total oracle-style properties | 42 |
| Exact-parity properties | 42 |
| Quality-floor properties | 3 (Louvain, Leiden, Label Propagation) |
| Adapter health tests | 1 (10 round-trip checks) |
| Fast-suite runtime | ~5 s (3 700+ tests) |
| Oracle-suite runtime | ~160 s |
Known Semantic Differences
These are intentional or documented divergences where YogEx and NetworkX use different conventions. The oracle adapters normalise where possible; otherwise the test generators avoid the divergence.
| Topic | YogEx | NetworkX | Oracle strategy |
|---|---|---|---|
| Self-loop degree | Counts as +1 (one outgoing entry) | Counts as +2 (in + out) | Avoid self-loops in degree-centrality tests |
| Transitive closure weights | New edges get weight 1 | New edges have no weight attribute | Compare edge existence only |
| Transitive reduction weights | Preserves original weights | Strips weights to {} | Compare edge existence only |
| Disconnected pathfinding | Returns {:error, :no_path} | Raises NetworkXNoPath | Adapter catches and normalises to {:error, :no_path} |
| Negative cycles (Bellman-Ford) | Returns {:error, :negative_cycle} | Raises NetworkXUnbounded | Adapter catches and normalises |
Centrality Deferral
The centrality category is split into two piles for the next re-engagement pass:
Numeric convention deltas — both sides terminate, but values differ by a documented factor or normalization option. Tractable with adapter-side flags or Elixir-side normalization.
- Closeness on disconnected graphs (Wasserman-Faust correction)
- Harmonic on isolated nodes
- Betweenness normalization options
Convergence / stability failures — one side raises or values disagree non-deterministically. Requires the input generator to exclude pathological inputs.
- Eigenvector on dominant-eigenvalue ambiguity
- Katz when
α · ρ(A) ≥ 1 - PageRank on dangling nodes
Running the Oracle Suite
Prerequisites
# Python dependencies
pip install -r test/oracle/scripts/requirements.txt
# Contents: networkx==3.6.1, numpy>=1.24, scipy>=1.11
Fast suite (excludes oracle)
mix test
Oracle suite only
mix test --include oracle test/oracle/
Single oracle module
mix test --include oracle test/oracle/pathfinding_oracle_test.exs
CI Configuration
The project uses a split CI strategy:
| Workflow | File | Tags | Runtime |
|---|---|---|---|
| Main CI | .github/workflows/ci.yml | Excludes :oracle | ~5 s |
| Nightly | .github/workflows/nightly.yml | Includes :oracle | ~160 s |
File Layout
test/oracle/
├── README.md # This file
├── nx_oracle.ex # Elixir harness (marshal, spawn, decode)
├── nx_oracle_test.exs # Adapter health check (10 self-tests)
├── pathfinding_oracle_test.exs # 8 properties
├── flow_oracle_test.exs # 3 properties
├── mst_oracle_test.exs # 4 properties (arborescence deferred — 🔴 divergent root choice)
├── centrality_oracle_test.exs # 3 properties (degree exact; rest deferred)
├── connectivity_oracle_test.exs # 3 properties
├── properties_oracle_test.exs # 5 properties
├── matching_oracle_test.exs # 4 properties
├── traversal_oracle_test.exs # 3 properties
└── scripts/
├── run_algorithm.py # Python entry point
├── requirements.txt # Pinned Python deps
└── adapters/
├── __init__.py # Dispatch table
├── centrality.py
├── connectivity.py
├── flow.py
├── matching.py
├── mst.py
├── pathfinding.py
├── properties.py
└── traversal.py
Adding a New Oracle Property
Add the NetworkX adapter in
test/oracle/scripts/adapters/<category>.py:def my_algorithm(graph, options): return nx.my_algorithm(graph, **options)Register it in the module's
DISPATCHdict and inadapters/__init__.py.Add the Elixir property in
test/oracle/<category>_oracle_test.exs:@tag :oracle property "P-ORAC-CAT-NNN My algorithm agrees with NetworkX" do check all(graph <- my_generator(), max_runs: 50) do yog_result = Yog.MyModule.my_algorithm(graph) nx_result = NetworkX.run("my_algorithm", graph, []) assert yog_result == nx_result end endRun the suite:
mix test --include oracle test/oracle/<category>_oracle_test.exs
References
- NetworkX Documentation
- StreamData Property-Based Testing
- ALGORITHMS.md — YogEx algorithm catalog