NetworkX Oracle Test Suite

Copy Markdown View Source

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                            
                       
  1. Graph marshallingYog.Oracle.NetworkX.encode_graph/1 serialises a Yog.Graph to JSON. Atoms are converted to strings, and other JSON-compatible types (integers, strings) are passed through as-is.
  2. Subprocess dispatchSystem.cmd/3 spawns python3 run_algorithm.py <tmpfile>. Each call is isolated, debuggable, and avoids NIF complexity.
  3. Result decodingdecode_result/2 reverses the encoding and normalises shapes (e.g. "__Inf__":infinity, nested dicts → %{node_id => value}).
  4. 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):

#TestValidates
1Empty graphZero nodes, zero edges
2Single node, no edgesNode presence without edges
3Single directed edgeDirected edge round-trip
4Self-loop (directed)Self-loop encoding
5Self-loop (undirected)Undirected self-loop encoding
6Disconnected componentsMultiple components survive JSON
7Atom node IDs:foo"foo" (converted to string)
8String node IDs"alice" round-trip
9Integer node IDs42 round-trip
10Weighted 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

CategoryAlgorithmYogEx ModuleNetworkX EquivalentParity TypePropertiesStatus
PathfindingDijkstra SSSPYog.Pathfinding.Dijkstranx.single_source_dijkstra_path_lengthExact distance maps2
A* shortest pathYog.Pathfinding.AStarnx.astar_pathExact path length1
Bellman-FordYog.Pathfinding.BellmanFordnx.bellman_ford_path_lengthExact path + error cases1
Floyd-WarshallYog.Pathfinding.FloydWarshallnx.floyd_warshallExact all-pairs distances1
Johnson'sYog.Pathfinding.Johnsonnx.johnsonExact all-pairs distances1
Bidirectional DijkstraYog.Pathfinding.Bidirectionalnx.bidirectional_dijkstraExact path + length1
Bidirectional BFSYog.Pathfinding.Bidirectionalnx.bidirectional_shortest_pathExact unweighted path1
Yen k-shortestYog.Pathfinding.Yennx.shortest_simple_pathsExact k paths0⏸️
Flow & CutsEdmonds-Karp max flowYog.Flow.MaxFlownx.maximum_flow_value (EK)Exact flow value1
Dinic max flowYog.Flow.MaxFlownx.maximum_flow_value (Dinic)Exact flow value1
Stoer-Wagner min cutYog.Flow.MinCutnx.stoer_wagnerExact cut value1
Spanning TreeKruskal MSTYog.MSTnx.minimum_spanning_tree (Kruskal)Exact total weight1
Prim MSTYog.MSTnx.minimum_spanning_tree (Prim)Exact total weight1
Borůvka MSTYog.MSTnx.minimum_spanning_tree (Borůvka)Exact total weight1
Maximum ST (Kruskal)Yog.MSTnx.maximum_spanning_treeExact total weight1
Min ArborescenceYog.MSTnx.minimum_spanning_arborescenceExact total weight0🔴
MatchingHopcroft-KarpYog.Matchingnx.bipartite.maximum_matchingExact cardinality1
Blossom (general)Yog.Matchingnx.max_weight_matchingExact cardinality1
Hungarian (min)Yog.Matchingnx.bipartite.minimum_weight_full_matchingExact optimal weight1
Hungarian (max)Yog.Matchingnx.bipartite.minimum_weight_full_matching (negated)Exact optimal weight1
ConnectivitySCC (Tarjan)Yog.Connectivity.SCCnx.strongly_connected_componentsExact component sets1
Connected ComponentsYog.Connectivitynx.connected_componentsExact component sets1
Weakly CCYog.Connectivity.Componentsnx.weakly_connected_componentsExact component sets1
PropertiesBipartite checkYog.Property.Bipartitenx.is_bipartiteExact boolean1
Tree checkYog.Property.Structurenx.is_treeExact boolean1
Forest checkYog.Property.Structurenx.is_forestExact boolean1
DAG checkYog.Property.Cyclicitynx.is_directed_acyclic_graphExact boolean1
Clique numberYog.Property.Cliquenx.find_cliques + max sizeExact size1
IsomorphismYog.Operationnx.is_isomorphicExact boolean1
WL Graph HashYog.Property.WeisfeilerLehmanLocal isomorphism parityHash equality1
TraversalBFS layersYog.Traversalnx.bfs_layersExact level sets1
Lexicographic topo-sortYog.Traversal.Sortnx.lexicographical_topological_sortExact ordering1
Topological generationsYog.DAG.Algorithmnx.topological_generationsExact generation lists1
CentralityDegree centralityYog.Centralitynx.degree_centralityExact values1
In-degree centralityYog.Centralitynx.in_degree_centralityExact values1
Out-degree centralityYog.Centralitynx.out_degree_centralityExact values1
Closeness centralityYog.Centralitynx.closeness_centralityExact values1
Harmonic centralityYog.Centralitynx.harmonic_centralityExact values1
Betweenness centralityYog.Centralitynx.betweenness_centralityExact values2
PageRankYog.Centralitynx.pagerankTolerance-based1
HITSYog.Centralitynx.hitsTolerance-based0🔴
Katz centralityYog.Centralitynx.katz_centralityTolerance-based1
Eigenvector centralityYog.Centralitynx.eigenvector_centralityTolerance-based1
CommunityLouvainYog.Community.Louvainnx.community.louvain_communitiesNMI ≥ 0.851
LeidenYog.Community.Leidennx.community.louvain_communities*NMI ≥ 0.901
Label PropagationYog.Community.LabelPropagationnx.community.label_propagation_communitiesNMI ≥ 0.701

* Leiden uses Louvain as the NetworkX proxy because NetworkX does not ship a native Leiden implementation.

Legend

SymbolMeaning
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

MetricValue
Total oracle-style properties42
Exact-parity properties42
Quality-floor properties3 (Louvain, Leiden, Label Propagation)
Adapter health tests1 (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.

TopicYogExNetworkXOracle strategy
Self-loop degreeCounts as +1 (one outgoing entry)Counts as +2 (in + out)Avoid self-loops in degree-centrality tests
Transitive closure weightsNew edges get weight 1New edges have no weight attributeCompare edge existence only
Transitive reduction weightsPreserves original weightsStrips weights to {}Compare edge existence only
Disconnected pathfindingReturns {:error, :no_path}Raises NetworkXNoPathAdapter catches and normalises to {:error, :no_path}
Negative cycles (Bellman-Ford)Returns {:error, :negative_cycle}Raises NetworkXUnboundedAdapter catches and normalises

Centrality Deferral

The centrality category is split into two piles for the next re-engagement pass:

  1. 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
  2. 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:

WorkflowFileTagsRuntime
Main CI.github/workflows/ci.ymlExcludes :oracle~5 s
Nightly.github/workflows/nightly.ymlIncludes :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

  1. 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 DISPATCH dict and in adapters/__init__.py.

  2. 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
    end
  3. Run the suite:

    mix test --include oracle test/oracle/<category>_oracle_test.exs
    

References