graph TD A["digraphx ๐\nPython"] -- "native yield" --> D["Same algorithms\nHoward 1960\nBellman-Ford\nLawler / Dinkelbach"] B["digraphx-cpp โ๏ธ\nC++20"] -- "py::Generator\ncoroutines" --> D C["digraphx-rs ๐ฆ\nRust"] -- "genawaiter::sync::Gen" --> D style A fill:#e3f2fd,stroke:#1565c0,stroke-width:3px style B fill:#e3f2fd,stroke:#1565c0,stroke-width:3px style C fill:#e3f2fd,stroke:#1565c0,stroke-width:3px style D fill:#fff9c4,stroke:#f57f17,stroke-width:3px
graph LR A["1. Relax every edge\n(Bellman-Ford pass)"] --> B{"Any d changed?"} B -- Yes --> C["2. Find cycles in\nthe predecessor graph"] C --> D{"Cycle found?"} D -- Yes --> E["3. Verify negative\nyield cycle ๐ด"] D -- No --> A B -- No --> F["โ No negative cycle"] style A fill:#fff9c4,stroke:#f57f17,stroke-width:3px style B fill:#fff9c4,stroke:#f57f17,stroke-width:3px style C fill:#d1c4e9,stroke:#6a1b9a,stroke-width:3px style D fill:#fff9c4,stroke:#f57f17,stroke-width:3px style E fill:#ffccbc,stroke:#bf360c,stroke-width:3px style F fill:#e8f5e9,stroke:#2e7d32,stroke-width:3px
graph LR S["Start rโ"] --> H["howard(dist, r)\nfind negative cycles"] H --> Q{"cycle found\nwith ratio r(C) < r?"} Q -- Yes --> U["r โ r(C)\n(zero_cancel)"] U --> H Q -- No --> D["โ r = min cycle ratio"] style S fill:#e3f2fd,stroke:#1565c0,stroke-width:3px style H fill:#fff9c4,stroke:#f57f17,stroke-width:3px style Q fill:#f9e79f,stroke:#f57f17,stroke-width:3px style U fill:#d1c4e9,stroke:#6a1b9a,stroke-width:3px style D fill:#e8f5e9,stroke:#2e7d32,stroke-width:3px
Where the 0.46 s went โ self time Fraction.__new__ 0.059 s ยท 23,114 calls fractions.forward 0.049 s ยท normalization ฯ.distance(ratio, e) 0.033 s ยท 7,673 calls isinstance / abc checks 0.031 s Also: math.gcd called 35,056 times โ every Fraction add/sub normalizes. The bottleneck is not the graph algorithm. It is the arithmetic inside the weight function. ๐
One howard() call โ n = 80, E = 384, ratio fixed unique edge weights needed 384 get_weight() evaluations 7,673 ยท 20ร redundant 20 passes ร 384 edges. The ratio โ and therefore every distance โ is unchanged. Each redundant evaluation builds a fresh Fraction and runs a gcd. ๐ธ
MinCycleRatio ยท controlled min-of-3, identical results MCR ยท n = 80 ๐ 2.60ร ยท 124.1 โ 47.6 ms MCR ยท n = 500 ๐ 2.35ร ยท 745.7 โ 317.6 ms full pytest suite ๐งช 1.97ร ยท 40.3 โ 20.5 s Bar width โ speedup. Same ratio returned, same cycles found โ byte-identical output. โ The suite runtime halved as a side effect: every MCR test got the same 2ร for free. ๐
Build 5,000 nodes / 25,000 edges TinyDiGraph โ before 222.1 ms TinyDiGraph โ after 68.8 ms ยท 3.2ร faster DiGraphAdapter (baseline) 80.2 ms The fix: bypass the generic path with direct list access โ 50 K contains + 75 K getitem calls removed. TinyDiGraph now runs at 0.86ร the plain adapter โ it used to be 2.41ร slower. ๐
Standalone howard ยท long negative cycle ยท log-scaled bars N = 300 2.8 ms โ 80.8 ms ยท 29ร โ N = 1000 9.2 ms โ 1006 ms ยท 109ร โ N = 3000 28.1 ms โ 9470 ms ยท 337ร โ ๐ฉ full sweeps ๐ฅ queue-based (SPFA) Python's per-node deque overhead plus count-based detection loses badly to sweep + find_cycle. The full sweep already finds cycles quickly. The "smarter" schedule was 337ร slower. ๐
C++ NegCycleFinder ยท n = 1,000,000 ยท interleaved A/B baseline 150 ms eager flatten โ 314 ms ยท 2.1ร slower lazy cache โ 151 ms ยท neutral A single-pass search pays for the whole copy and gets nothing back. ๐๏ธ The port must respect the host language's cost model โ not transcribe the source. ๐
graph LR P0["pass 0\nplain loop\nno cache"] --> P1["pass 1\nrelax + build\nweights[]"] P1 --> P2["pass โฅ 2\nrelax using\ncached weights"] style P0 fill:#e8f5e9,stroke:#2e7d32,stroke-width:3px style P1 fill:#fff9c4,stroke:#f57f17,stroke-width:3px style P2 fill:#a3be8c,stroke:#2e7d32,stroke-width:4px
graph LR R["The redundancy:\nweight recomputed\nper pass"] --> PY["๐ Python\nbottleneck = interpreter\n+ Fraction gcd"] R --> CPP["โ๏ธ C++\nbottleneck = copying\n& allocation"] R --> RS["๐ฆ Rust\nbottleneck = nothing\n(monomorphized)"] PY --> FIX["Fix:\ncompute once,\nreuse per call"] CPP --> FIX RS --> FIX style R fill:#ffccbc,stroke:#bf360c,stroke-width:3px style PY fill:#e3f2fd,stroke:#1565c0,stroke-width:3px style CPP fill:#d1c4e9,stroke:#6a1b9a,stroke-width:3px style RS fill:#fff9c4,stroke:#f57f17,stroke-width:3px style FIX fill:#e8f5e9,stroke:#2e7d32,stroke-width:4px
Identical binaries, measured minutes apart C++ n = 100 K ยท run A 10.5 ms C++ n = 100 K ยท run B 5.0 ms ยท 2.1ร drift Rust howard_ratio ยท spread 24.5 โ 31.9 ยตs ๐ฌ Nothing changed. Only the machine state. Absolute timings are not comparable across runs.
Speedup โ bar width โ speedup MCR ยท Python ๐ 2.60ร ยท 124.1 โ 47.6 ms param solver ยท Rust ๐ฆ 2.20ร ยท 13.1 โ 5.9 ยตs multi-pass cache ยท Rust ๐ฆ 1.35ร multi-pass cache ยท C++ โ๏ธ 1.25ร The win lands wherever the weight function is non-trivial and reused โ exactly the parametric path. โ